From 0c392eebe41f000ff5aff4e85e98c75ba9ea5f77 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Thu, 3 Sep 2026 14:22:29 +0800 Subject: [PATCH] feat(frontend): complete MCP plugin management UI --- README.md | 2 +- .../components/common/CommandPalette.spec.ts | 77 +++++ .../src/components/common/CommandPalette.vue | 95 +++++- frontend/src/components/common/TitleBar.vue | 2 +- .../src/features/editor/EditorPane.spec.ts | 3 + .../features/plugins/PluginMcpPanel.spec.ts | 109 +++++++ .../src/features/plugins/PluginMcpPanel.vue | 275 ++++++++++++++++++ frontend/src/features/plugins/PluginsView.vue | 4 +- .../features/workspace/FileTreePanel.spec.ts | 7 +- frontend/src/router/index.ts | 12 +- .../src/services/workspaceService.spec.ts | 2 +- frontend/src/services/workspaceService.ts | 14 +- frontend/src/stores/editor.ts | 6 +- frontend/src/stores/workspace.ts | 6 +- 14 files changed, 596 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/common/CommandPalette.spec.ts create mode 100644 frontend/src/features/plugins/PluginMcpPanel.spec.ts create mode 100644 frontend/src/features/plugins/PluginMcpPanel.vue diff --git a/README.md b/README.md index a8b7637..cd85754 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 136 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 136 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/frontend/src/components/common/CommandPalette.spec.ts b/frontend/src/components/common/CommandPalette.spec.ts new file mode 100644 index 0000000..114cd32 --- /dev/null +++ b/frontend/src/components/common/CommandPalette.spec.ts @@ -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() + 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: '
' } }], + }) + 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() + }) +}) diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue index 9ab6aa5..b5a99f6 100644 --- a/frontend/src/components/common/CommandPalette.vue +++ b/frontend/src/components/common/CommandPalette.vue @@ -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(null) +const pluginCommands = ref([]) +const commandError = ref('') +const commandNotice = ref('') +const selectionSnapshot = ref(null) interface Command { id: string; label: string; hint: string; run: () => void | Promise } -const commands = computed(() => [ +const builtinCommands = computed(() => [ { 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(() => [ { id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote }, ]) +const commands = computed(() => [ + ...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 = { + '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))