feat(frontend): complete MCP plugin management UI
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as pluginService from '@/services/pluginService'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import CommandPalette from './CommandPalette.vue'
|
||||
|
||||
vi.mock('@/services/pluginService', async (loadOriginal) => {
|
||||
const original = await loadOriginal<typeof import('@/services/pluginService')>()
|
||||
return { ...original, listPluginCommands: vi.fn(), executePluginCommand: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(pluginService.listPluginCommands).mockResolvedValue([{
|
||||
command_id: 'demo.selection',
|
||||
plugin_id: 'demo',
|
||||
title: '处理选区',
|
||||
description: '',
|
||||
icon: null,
|
||||
locations: ['command_palette'],
|
||||
when: ['workspace.has_vault', 'editor.has_note', 'editor.has_selection'],
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
enabled: true,
|
||||
}])
|
||||
vi.mocked(pluginService.executePluginCommand).mockResolvedValue({
|
||||
command_id: 'demo.selection',
|
||||
status: 'completed',
|
||||
effect: { type: 'notification', payload: { level: 'success', message: '完成' } },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('CommandPalette Plugin Command', () => {
|
||||
it('filters by when context and sends stable backend identities plus the captured selection', async () => {
|
||||
const workspace = useWorkspaceStore()
|
||||
workspace.hasVault = true
|
||||
workspace.vaultId = 'vault-default'
|
||||
const editor = useEditorStore()
|
||||
editor.currentNoteId = 'note-1'
|
||||
editor.currentFilePath = '/note.md'
|
||||
|
||||
vi.spyOn(window, 'getSelection').mockReturnValue({
|
||||
toString: () => 'selected text',
|
||||
} as Selection)
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/')
|
||||
const wrapper = mount(CommandPalette, { attachTo: document.body, global: { plugins: [router] } })
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true }))
|
||||
await flushPromises()
|
||||
const command = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('处理选区'))
|
||||
expect(command).toBeTruthy()
|
||||
command!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(pluginService.executePluginCommand).toHaveBeenCalledWith('demo.selection', {}, {
|
||||
vault_id: 'vault-default',
|
||||
note_id: 'note-1',
|
||||
file_path: '/note.md',
|
||||
selection: 'selected text',
|
||||
})
|
||||
expect(document.body.textContent).toContain('完成')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -5,18 +5,26 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
import * as pluginService from '@/services/pluginService'
|
||||
import type { PluginCommand, PluginCommandEffect } from '@/contracts'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
|
||||
const router = useRouter()
|
||||
const editorStore = useEditorStore()
|
||||
const themeStore = useThemeStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const pluginStore = usePluginStore()
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const pluginCommands = ref<PluginCommand[]>([])
|
||||
const commandError = ref('')
|
||||
const commandNotice = ref('')
|
||||
const selectionSnapshot = ref<string | null>(null)
|
||||
|
||||
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
|
||||
|
||||
const commands = computed<Command[]>(() => [
|
||||
const builtinCommands = computed<Command[]>(() => [
|
||||
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
|
||||
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
|
||||
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
|
||||
@@ -28,14 +36,37 @@ const commands = computed<Command[]>(() => [
|
||||
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote },
|
||||
])
|
||||
|
||||
const commands = computed<Command[]>(() => [
|
||||
...builtinCommands.value,
|
||||
...pluginCommands.value.filter(isPluginCommandAvailable).map((command) => ({
|
||||
id: 'plugin:' + command.command_id,
|
||||
label: command.title,
|
||||
hint: 'Plugin · ' + command.plugin_id,
|
||||
run: () => executePluginCommand(command),
|
||||
})),
|
||||
])
|
||||
|
||||
function isPluginCommandAvailable(command: PluginCommand) {
|
||||
if (!command.enabled) return false
|
||||
return command.when.every((condition) => {
|
||||
if (condition === 'workspace.has_vault') return Boolean(workspaceStore.vaultId)
|
||||
if (condition === 'editor.has_note') return Boolean(editorStore.currentNoteId)
|
||||
if (condition === 'editor.has_selection') return Boolean(selectionSnapshot.value)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const filteredCommands = computed(() => {
|
||||
const value = query.value.trim().toLocaleLowerCase()
|
||||
return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value
|
||||
})
|
||||
|
||||
function show() {
|
||||
selectionSnapshot.value = window.getSelection()?.toString() || null
|
||||
open.value = true
|
||||
query.value = ''
|
||||
commandError.value = ''
|
||||
void loadPluginCommands()
|
||||
void nextTick(() => input.value?.focus())
|
||||
}
|
||||
|
||||
@@ -44,7 +75,11 @@ function hide() { open.value = false }
|
||||
async function execute(command: Command | undefined) {
|
||||
if (!command) return
|
||||
hide()
|
||||
await command.run()
|
||||
try {
|
||||
await command.run()
|
||||
} catch (error) {
|
||||
commandNotice.value = error instanceof Error ? error.message : '命令执行失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
@@ -58,6 +93,55 @@ async function createNote() {
|
||||
await router.push('/workspace')
|
||||
}
|
||||
|
||||
async function loadPluginCommands() {
|
||||
try {
|
||||
pluginCommands.value = await pluginService.listPluginCommands('command_palette')
|
||||
} catch (error) {
|
||||
commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
function hasRequiredArguments(command: PluginCommand) {
|
||||
return Array.isArray(command.parameters.required) && command.parameters.required.length > 0
|
||||
}
|
||||
|
||||
async function executePluginCommand(command: PluginCommand) {
|
||||
if (hasRequiredArguments(command)) {
|
||||
pluginStore.selectPlugin(command.plugin_id)
|
||||
await router.push('/extensions/plugins')
|
||||
commandNotice.value = '请在 Plugin 详情页填写参数后执行“' + command.title + '”。'
|
||||
return
|
||||
}
|
||||
const result = await pluginService.executePluginCommand(command.command_id, {}, {
|
||||
vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null,
|
||||
note_id: editorStore.currentNoteId,
|
||||
file_path: editorStore.currentFilePath,
|
||||
selection: selectionSnapshot.value,
|
||||
})
|
||||
await applyPluginEffect(result.effect)
|
||||
}
|
||||
|
||||
async function applyPluginEffect(effect: PluginCommandEffect) {
|
||||
if (effect.type === 'notification') { commandNotice.value = effect.payload.message; return }
|
||||
if (effect.type === 'navigate') {
|
||||
const routes: Record<string, string> = {
|
||||
'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat',
|
||||
agent: '/agent/runs', tasks: '/tasks', skills: '/extensions/skills',
|
||||
plugins: '/extensions/plugins', themes: '/themes', settings: '/settings',
|
||||
}
|
||||
await router.push(routes[effect.payload.route])
|
||||
return
|
||||
}
|
||||
if (effect.type === 'refresh') {
|
||||
if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins()
|
||||
if (effect.payload.scope === 'commands') await loadPluginCommands()
|
||||
commandNotice.value = '相关数据已刷新。'
|
||||
return
|
||||
}
|
||||
if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return }
|
||||
commandNotice.value = 'Plugin 命令执行完成。'
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
|
||||
event.preventDefault()
|
||||
@@ -72,10 +156,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="commandNotice" class="command-toast" role="status">
|
||||
<span>{{ commandNotice }}</span><button aria-label="关闭通知" @click="commandNotice = ''">×</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="command-backdrop" @click.self="hide">
|
||||
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板">
|
||||
<input ref="input" v-model="query" class="command-input" placeholder="输入命令…" @keydown.enter.prevent="execute(filteredCommands[0])" />
|
||||
<p v-if="commandError" class="command-error">{{ commandError }}</p>
|
||||
<div class="command-list">
|
||||
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
|
||||
<span>{{ command.label }}</span><small>{{ command.hint }}</small>
|
||||
@@ -99,6 +187,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
.command-list small, .command-list p, footer { color: var(--color-text-tertiary); }
|
||||
.command-list p { padding: var(--space-xl); text-align: center; }
|
||||
footer { display: flex; gap: var(--space-lg); padding: var(--space-sm) var(--space-lg); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-xs); }
|
||||
.command-error { margin: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); font-size: var(--font-size-sm); }
|
||||
.command-toast { position: fixed; top: 48px; right: var(--space-xl); z-index: calc(var(--z-modal) + 1); display: flex; align-items: center; gap: var(--space-lg); max-width: min(420px, calc(100vw - 32px)); padding: var(--space-md) var(--space-lg); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-lg); animation: notice-in var(--motion-normal) both; }
|
||||
.command-toast button { color: var(--color-text-tertiary); font-size: var(--font-size-xl); }
|
||||
|
||||
@keyframes command-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes command-palette-in { from { opacity: 0; transform: translateY(-8px) scale(.99); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
|
||||
@@ -21,7 +21,7 @@ const pageTitle = computed(() => {
|
||||
agent: '智能体执行轨迹',
|
||||
tasks: '任务',
|
||||
skills: 'Skill 管理',
|
||||
plugins: 'Plugin 管理',
|
||||
plugins: 'Plugin 与 MCP',
|
||||
themes: '主题管理',
|
||||
settings: '设置',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user