feat(frontend): complete MCP plugin management UI
This commit is contained in:
@@ -118,7 +118,7 @@ cd frontend
|
||||
pnpm test
|
||||
```
|
||||
|
||||
当前回归基线为后端 136 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
当前回归基线为后端 136 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||
|
||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||
|
||||
|
||||
@@ -2329,7 +2329,7 @@ Markdown Workspace
|
||||
|
||||
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
|
||||
|
||||
截至 2026-09-02,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host 以及 Plugin Command/Settings 后端 Contract 与前端 Service 也已完成。当前验证基线为后端 136 项测试、前端 29 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
截至 2026-09-03,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host,以及 Plugin Command/Settings 前后端闭环也已完成。Plugin 详情页现已提供 Host 状态、重启、动态设置、Secret 管理和命令执行,全局命令面板可加载 Plugin Command。当前验证基线为后端 136 项测试、前端 32 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
|
||||
第二阶段在既有 Contract 上接入:
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
第二阶段继续保持第一阶段的模块 ownership:
|
||||
|
||||
- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、整体架构与代码审阅。
|
||||
- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、Plugin Command / Settings UI、整体架构与代码审阅。
|
||||
- 杨星萱:Knowledge Core、Retrieval Core、Benchmark、文档导出、函数图像绘制。
|
||||
- 吉海燕:Frontend、Theme、Agent Trace、Plugin UI Contribution、Mermaid 渲染。
|
||||
- 吉海燕:Frontend、Theme、Agent Trace、Mermaid 渲染。
|
||||
|
||||
---
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
| 成员 | 主要负责方向 | 第二阶段任务 | 配合事项 |
|
||||
| --- | --- | --- | --- |
|
||||
| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command Contribution 后端、Plugin Settings Contribution 后端、更多 Provider、整体集成、代码审阅与统筹 | 与吉海燕联调 Plugin Command / Settings 前端;为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 |
|
||||
| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command / Settings 前后端闭环、更多 Provider、整体集成、代码审阅与统筹 | 为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 |
|
||||
| 杨星萱 | Knowledge Core / Retrieval Core / Benchmark / Export / 数学内容渲染 | RAG Benchmark、Agent Benchmark 基础设施、Markdown → HTML / PDF / DOCX、函数图像绘制与渲染支持、Retrieval 调优 | 与范涵宇确认 Agent Benchmark 事件和测试数据结构;与吉海燕联调函数图像在编辑器和预览区中的展示 |
|
||||
| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Plugin Command / Settings 前端、Mermaid 渲染支持 | 与范涵宇联调 Plugin Contribution Contract 与 AgentEvent;与杨星萱联调函数图像及导出预览 |
|
||||
| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Mermaid 渲染支持 | 与范涵宇联调 AgentEvent;与杨星萱联调函数图像及导出预览 |
|
||||
|
||||
---
|
||||
|
||||
@@ -384,7 +384,7 @@ Metadata Filter
|
||||
|
||||
---
|
||||
|
||||
## 五、吉海燕
|
||||
## 五、前端展示层(吉海燕;5.4—5.5 由范涵宇负责)
|
||||
|
||||
### 5.1 Theme Import
|
||||
|
||||
@@ -488,7 +488,7 @@ Error
|
||||
- 用户取消;
|
||||
- Citation 跳转。
|
||||
|
||||
### 5.4 Plugin Command 前端
|
||||
### 5.4 Plugin Command 前端(范涵宇)
|
||||
|
||||
负责 Command Contribution 在前端呈现。
|
||||
|
||||
@@ -502,7 +502,7 @@ Toolbar Action
|
||||
|
||||
前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。
|
||||
|
||||
### 5.5 Plugin Settings 前端
|
||||
### 5.5 Plugin Settings 前端(范涵宇)
|
||||
|
||||
根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。
|
||||
|
||||
@@ -572,9 +572,9 @@ Mermaid 渲染需要与 Theme Design Token 联动。
|
||||
| --- | --- | --- |
|
||||
| MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 |
|
||||
| Plugin Command Runtime | 范涵宇 | 吉海燕 |
|
||||
| Plugin Command UI | 吉海燕 | 范涵宇 |
|
||||
| Plugin Command UI | 范涵宇 | 吉海燕 |
|
||||
| Plugin Settings Runtime | 范涵宇 | 吉海燕 |
|
||||
| Plugin Settings UI | 吉海燕 | 范涵宇 |
|
||||
| Plugin Settings UI | 范涵宇 | 吉海燕 |
|
||||
| Agent Benchmark Framework | 杨星萱 | 范涵宇 |
|
||||
| Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 |
|
||||
| Agent Trace Visualization | 吉海燕 | 范涵宇 |
|
||||
@@ -750,6 +750,8 @@ Markdown
|
||||
- [x] Agent 能调用 MCP Tool;
|
||||
- [x] Plugin Command Contribution 后端可注册;
|
||||
- [x] Plugin Settings Contribution 后端可解析;
|
||||
- [x] Plugin Command 可以显示并从前端执行;
|
||||
- [x] Plugin Settings 可以动态生成设置项并独立提交 Secret;
|
||||
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
|
||||
- [ ] 完成跨模块接口审阅和第二阶段集成。
|
||||
|
||||
@@ -773,8 +775,6 @@ Markdown
|
||||
- [ ] Theme 可以启用、停用和卸载;
|
||||
- [ ] Agent Trace 可以展示完整 Tool Call 顺序;
|
||||
- [ ] Trace Node 可以查看参数、结果、耗时和错误;
|
||||
- [ ] Plugin Command 可以显示在前端;
|
||||
- [ ] Plugin Settings 可以动态生成设置项;
|
||||
- [ ] Markdown Mermaid Code Block 可以渲染;
|
||||
- [ ] Mermaid 支持主题切换;
|
||||
- [ ] Mermaid 渲染错误可以明确展示;
|
||||
@@ -791,6 +791,8 @@ Markdown
|
||||
├── MCP Bridge
|
||||
├── Plugin Command Runtime
|
||||
├── Plugin Settings Runtime
|
||||
├── Plugin Command UI
|
||||
├── Plugin Settings UI
|
||||
├── Provider Adapter
|
||||
├── Code Review
|
||||
└── Integration / Coordination
|
||||
@@ -812,8 +814,6 @@ Markdown
|
||||
├── Theme Import
|
||||
├── Theme Community Format
|
||||
├── Agent Trace Visualization
|
||||
├── Plugin Command UI
|
||||
├── Plugin Settings UI
|
||||
└── Mermaid
|
||||
├── Markdown Rendering
|
||||
├── Theme Adaptation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plugin Command 与 Settings 开发说明
|
||||
|
||||
> 更新日期:2026-09-02。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界和前端 Service Contract。当前回归基线为后端 136 项测试、前端 29 项测试,TypeScript 类型检查和生产构建通过。
|
||||
> 更新日期:2026-09-03。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界及前端闭环。当前回归基线为后端 136 项测试、前端 32 项测试,TypeScript 类型检查和生产构建通过。
|
||||
|
||||
## 1. 阶段目标
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
- Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect;
|
||||
- Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用;
|
||||
- Frontend Contract:提供稳定的 TypeScript DTO 与 Service,供后续命令面板、右键菜单和插件设置页直接联调。
|
||||
- Frontend:Plugin 详情页提供 Host 状态、动态设置、Secret 管理与命令执行,全局命令面板加载 command_palette Contribution。
|
||||
|
||||
本阶段不实现前端页面,也不把第三方代码导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。
|
||||
第三方代码不会导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。
|
||||
|
||||
## 2. 包内声明
|
||||
|
||||
@@ -86,7 +86,9 @@ PUT /api/plugins/{plugin_id}/settings/{key}/secret
|
||||
DELETE /api/plugins/{plugin_id}/settings/{key}/secret
|
||||
```
|
||||
|
||||
前端 `pluginService` 已提供对应方法及 Wire DTO,但阶段 D 不创建命令面板或动态设置表单页面。调用方必须使用服务层,不自行拼接路径;Secret 不得写入 Pinia、LocalStorage 或调试日志。
|
||||
前端 `pluginService` 提供对应方法及 Wire DTO,Plugin 详情页据此展示 MCP Host 状态和重启入口、动态生成五类设置字段、独立写入或删除 Secret,并执行带参数的 Plugin Command。全局命令面板打开时获取 `command_palette` 命令;需要必填参数的命令会引导用户进入详情页填写。调用方必须使用服务层,不自行拼接路径。
|
||||
|
||||
Secret 明文仅存在于当前密码输入框绑定的组件内存,提交后立即清空;不得写入 Pinia、LocalStorage、普通 Settings 请求或调试日志。前端不会读取 Secret 明文,只展示后端返回的 `configured` 状态。
|
||||
|
||||
## 6. 主要错误边界
|
||||
|
||||
@@ -110,6 +112,6 @@ pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径和前端 Service 请求格式。
|
||||
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径、前端 Service 请求格式、Host 状态展示和动态 Secret 表单。
|
||||
|
||||
生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。
|
||||
|
||||
@@ -453,7 +453,7 @@ git diff --check passed
|
||||
- 阶段 C.5 按规划放在第二阶段功能与测试完成后、第三阶段桌面集成正式构建 Tauri/Rust 沙箱之前;
|
||||
- 生产环境继续通过配置门禁拒绝未沙箱化的 MCP Host;
|
||||
- 桌面端将 Plugin Secret 从开发期 Fernet 文件迁移到 Stronghold 或系统 Keychain,保持现有引用和 HTTP Contract;
|
||||
- 前端后续实现命令面板、上下文菜单和动态 Settings 表单,继续复用现有 Service;
|
||||
- 前端已实现命令面板、Plugin 详情命令和动态 Settings / Secret 表单;上下文菜单与 Toolbar 挂载点仍复用现有 Service 继续扩展;
|
||||
- Command 审计当前是 500 条有界内存队列,长期审计持久化需在后续阶段单独设计;
|
||||
- 前端大 Chunk 应通过路由和 Markdown 依赖拆包处理,不与本次 Extension Contract 修改混合。
|
||||
|
||||
|
||||
@@ -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()
|
||||
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: '设置',
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ beforeEach(() => {
|
||||
if (filePath === '/数据结构/红黑树.md') return '# 红黑树\n\n新的文件内容'
|
||||
throw new Error(`Unexpected file path: ${filePath}`)
|
||||
})
|
||||
vi.spyOn(workspaceService, 'getNoteId').mockImplementation(async (filePath) =>
|
||||
filePath.includes('红黑树') ? 'note-rbt' : 'note-welcome'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Plugin } from '@/contracts'
|
||||
import * as pluginService from '@/services/pluginService'
|
||||
import PluginMcpPanel from './PluginMcpPanel.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
vi.mock('@/services/pluginService', async (loadOriginal) => {
|
||||
const original = await loadOriginal<typeof import('@/services/pluginService')>()
|
||||
return {
|
||||
...original,
|
||||
getPluginHostStatus: vi.fn(),
|
||||
restartPluginHost: vi.fn(),
|
||||
getPluginSettings: vi.fn(),
|
||||
updatePluginSettings: vi.fn(),
|
||||
putPluginSecret: vi.fn(),
|
||||
deletePluginSecret: vi.fn(),
|
||||
listPluginCommands: vi.fn(),
|
||||
executePluginCommand: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const plugin: Plugin = {
|
||||
plugin_id: 'mcp-demo',
|
||||
name: 'MCP Demo',
|
||||
version: '1.0.0',
|
||||
description: 'demo',
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
permissions: [],
|
||||
contributions: [
|
||||
{ type: 'settings_section', id: 'mcp-demo.general', name: 'settings' },
|
||||
{ type: 'command', id: 'mcp-demo.run', name: 'run' },
|
||||
],
|
||||
backend_type: 'mcp',
|
||||
transport: 'stdio',
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const pinia = createPinia()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/')
|
||||
const wrapper = mount(PluginMcpPanel, {
|
||||
props: { plugin },
|
||||
global: { plugins: [pinia, router], stubs: { AppIcon: true } },
|
||||
})
|
||||
const workspaceStore = useWorkspaceStore(pinia)
|
||||
workspaceStore.vaultId = 'default'
|
||||
workspaceStore.hasVault = true
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(pluginService.getPluginHostStatus).mockResolvedValue({
|
||||
plugin_id: 'mcp-demo', backend_type: 'mcp', transport: 'stdio',
|
||||
status: 'ready', tools_count: 2, server_name: 'demo',
|
||||
})
|
||||
vi.mocked(pluginService.getPluginSettings).mockResolvedValue({
|
||||
plugin_id: 'mcp-demo',
|
||||
schema_version: 1,
|
||||
fields: [
|
||||
{ key: 'limit', label: '数量', description: '', type: 'number', required: true, options: [] },
|
||||
{ key: 'api_key', label: 'API Key', description: '', type: 'secret', required: true, options: [] },
|
||||
],
|
||||
values: { limit: 5 },
|
||||
secrets: { api_key: { configured: false } },
|
||||
})
|
||||
vi.mocked(pluginService.putPluginSecret).mockResolvedValue({
|
||||
plugin_id: 'mcp-demo', key: 'api_key', configured: true,
|
||||
})
|
||||
vi.mocked(pluginService.listPluginCommands).mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('PluginMcpPanel', () => {
|
||||
it('loads MCP Host status and exposes restart controls', async () => {
|
||||
const wrapper = await render()
|
||||
await flushPromises()
|
||||
expect(pluginService.getPluginHostStatus).toHaveBeenCalledWith('mcp-demo')
|
||||
expect(wrapper.text()).toContain('demo')
|
||||
expect(wrapper.text()).toContain('工具数量')
|
||||
})
|
||||
|
||||
it('builds settings fields from schema and writes secrets separately', async () => {
|
||||
const wrapper = await render()
|
||||
const settingsTab = wrapper.findAll('button').find((button) => button.text() === '设置与密钥')
|
||||
expect(settingsTab).toBeTruthy()
|
||||
await settingsTab!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('数量')
|
||||
expect(wrapper.text()).toContain('API Key')
|
||||
|
||||
await wrapper.get('input[type="password"]').setValue('secret-only-in-request')
|
||||
const secretButton = wrapper.findAll('button').find((button) => button.text() === '安全保存')
|
||||
expect(secretButton).toBeTruthy()
|
||||
await secretButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(pluginService.putPluginSecret).toHaveBeenCalledWith('mcp-demo', 'api_key', 'secret-only-in-request')
|
||||
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||
expect(wrapper.text()).toContain('已配置')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { Key, Refresh, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import type { Plugin, PluginCommand, PluginHostStatus, PluginSettingField, PluginSettingsSchema } from '@/contracts'
|
||||
import * as pluginService from '@/services/pluginService'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
const props = defineProps<{ plugin: Plugin }>()
|
||||
const pluginStore = usePluginStore()
|
||||
const editorStore = useEditorStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<'host' | 'settings' | 'commands'>('host')
|
||||
const host = ref<PluginHostStatus | null>(null)
|
||||
const schema = ref<PluginSettingsSchema | null>(null)
|
||||
const values = ref<Record<string, unknown>>({})
|
||||
// 明文只停留在组件内存,提交后立即清空。
|
||||
const secrets = ref<Record<string, string>>({})
|
||||
const commands = ref<PluginCommand[]>([])
|
||||
const argumentsByCommand = ref<Record<string, Record<string, unknown>>>({})
|
||||
const loading = ref(false)
|
||||
const busy = ref('')
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
let loadVersion = 0
|
||||
|
||||
const hasSettings = computed(() => props.plugin.contributions.some((item) => item.type === 'settings_section'))
|
||||
const tabs = computed(() => [
|
||||
...(props.plugin.backend_type === 'mcp' ? [{ id: 'host' as const, label: 'MCP Host' }] : []),
|
||||
...(hasSettings.value ? [{ id: 'settings' as const, label: '设置与密钥' }] : []),
|
||||
{ id: 'commands' as const, label: '插件命令' },
|
||||
])
|
||||
|
||||
watch(() => props.plugin.plugin_id, () => {
|
||||
loadVersion++
|
||||
activeTab.value = props.plugin.backend_type === 'mcp' ? 'host' : hasSettings.value ? 'settings' : 'commands'
|
||||
host.value = null
|
||||
schema.value = null
|
||||
values.value = {}
|
||||
secrets.value = {}
|
||||
commands.value = []
|
||||
void loadActive()
|
||||
}, { immediate: true })
|
||||
|
||||
function feedback(message = '') { error.value = message; notice.value = '' }
|
||||
function message(reason: unknown, fallback: string) { return reason instanceof Error ? reason.message : fallback }
|
||||
function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString() : '—' }
|
||||
|
||||
async function selectTab(tab: typeof activeTab.value) {
|
||||
activeTab.value = tab
|
||||
await loadActive()
|
||||
}
|
||||
async function loadActive() {
|
||||
const version = ++loadVersion
|
||||
const pluginId = props.plugin.plugin_id
|
||||
const tab = activeTab.value
|
||||
feedback()
|
||||
loading.value = true
|
||||
try {
|
||||
if (tab === 'host') {
|
||||
const loadedHost = await pluginService.getPluginHostStatus(pluginId)
|
||||
if (version === loadVersion) host.value = loadedHost
|
||||
}
|
||||
if (tab === 'settings') {
|
||||
const loadedSchema = await pluginService.getPluginSettings(pluginId)
|
||||
if (version === loadVersion) {
|
||||
schema.value = loadedSchema
|
||||
values.value = { ...loadedSchema.values }
|
||||
}
|
||||
}
|
||||
if (tab === 'commands') {
|
||||
const loadedCommands = (await pluginService.listPluginCommands()).filter((command) => command.plugin_id === pluginId)
|
||||
if (version === loadVersion) {
|
||||
commands.value = loadedCommands
|
||||
for (const command of loadedCommands) argumentsByCommand.value[command.command_id] = {}
|
||||
}
|
||||
}
|
||||
} catch (reason) {
|
||||
if (version === loadVersion) feedback(message(reason, 'MCP 数据加载失败'))
|
||||
} finally {
|
||||
if (version === loadVersion) loading.value = false
|
||||
}
|
||||
}
|
||||
async function restartHost() {
|
||||
busy.value = 'host'
|
||||
feedback()
|
||||
try {
|
||||
await pluginService.restartPluginHost(props.plugin.plugin_id)
|
||||
host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id)
|
||||
await pluginStore.loadPlugins()
|
||||
notice.value = 'MCP Host 已重启。'
|
||||
} catch (reason) { feedback(message(reason, 'MCP Host 重启失败')) } finally { busy.value = '' }
|
||||
}
|
||||
function updateValue(field: PluginSettingField, raw: string | boolean) {
|
||||
values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw
|
||||
}
|
||||
async function saveSettings() {
|
||||
if (!schema.value) return
|
||||
busy.value = 'settings'
|
||||
feedback()
|
||||
try {
|
||||
schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value)
|
||||
values.value = { ...schema.value.values }
|
||||
notice.value = '普通设置已保存。'
|
||||
} catch (reason) { feedback(message(reason, '设置保存失败')) } finally { busy.value = '' }
|
||||
}
|
||||
async function saveSecret(field: PluginSettingField) {
|
||||
const secret = secrets.value[field.key]?.trim()
|
||||
if (!secret) { feedback('请输入' + field.label); return }
|
||||
busy.value = 'secret:' + field.key
|
||||
feedback()
|
||||
try {
|
||||
const state = await pluginService.putPluginSecret(props.plugin.plugin_id, field.key, secret)
|
||||
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
|
||||
secrets.value[field.key] = ''
|
||||
notice.value = field.label + '已加密保存。'
|
||||
} catch (reason) { feedback(message(reason, '密钥保存失败')) } finally { busy.value = '' }
|
||||
}
|
||||
async function deleteSecret(field: PluginSettingField) {
|
||||
if (!confirm('删除已保存的' + field.label + '?')) return
|
||||
busy.value = 'secret:' + field.key
|
||||
feedback()
|
||||
try {
|
||||
const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key)
|
||||
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
|
||||
secrets.value[field.key] = ''
|
||||
notice.value = field.label + '已删除。'
|
||||
} catch (reason) { feedback(message(reason, '密钥删除失败')) } finally { busy.value = '' }
|
||||
}
|
||||
function properties(command: PluginCommand): Record<string, Record<string, unknown>> {
|
||||
const result = command.parameters.properties
|
||||
return result && typeof result === 'object' && !Array.isArray(result) ? result as Record<string, Record<string, unknown>> : {}
|
||||
}
|
||||
function required(command: PluginCommand, key: string) {
|
||||
return Array.isArray(command.parameters.required) && command.parameters.required.includes(key)
|
||||
}
|
||||
function commandAvailable(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)
|
||||
// Plugin 详情页不冒充编辑器选区;选区命令应从命令面板或编辑器挂载点执行。
|
||||
if (condition === 'editor.has_selection') return false
|
||||
return false
|
||||
})
|
||||
}
|
||||
function updateArgument(commandId: string, key: string, raw: string, definition: Record<string, unknown>) {
|
||||
const target = argumentsByCommand.value[commandId] ??= {}
|
||||
if (definition.type === 'number' || definition.type === 'integer') target[key] = raw === '' ? undefined : Number(raw)
|
||||
else if (definition.type === 'boolean') target[key] = raw === 'true'
|
||||
else target[key] = raw
|
||||
}
|
||||
async function execute(command: PluginCommand) {
|
||||
busy.value = command.command_id
|
||||
feedback()
|
||||
try {
|
||||
const result = await pluginService.executePluginCommand(command.command_id, argumentsByCommand.value[command.command_id] ?? {}, {
|
||||
vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null,
|
||||
note_id: editorStore.currentNoteId,
|
||||
file_path: editorStore.currentFilePath,
|
||||
selection: null,
|
||||
})
|
||||
if (result.effect.type === 'notification') notice.value = result.effect.payload.message
|
||||
else if (result.effect.type === 'job') notice.value = '后台任务已创建:' + result.effect.payload.job_id
|
||||
else if (result.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[result.effect.payload.route])
|
||||
} else if (result.effect.type === 'refresh') {
|
||||
await loadActive()
|
||||
notice.value = '相关数据已刷新。'
|
||||
} else notice.value = '命令执行完成。'
|
||||
} catch (reason) { feedback(message(reason, '命令执行失败')) } finally { busy.value = '' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mcp-panel">
|
||||
<nav class="mcp-tabs" aria-label="MCP 与 Plugin 配置">
|
||||
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
|
||||
</nav>
|
||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||
<div v-if="notice" class="notice-banner">{{ notice }}</div>
|
||||
|
||||
<div v-if="activeTab === 'host'" class="mcp-section">
|
||||
<div class="section-head"><div><h3>MCP Host 状态</h3><p>查看协议协商、运行状态与 Host 错误。</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? '重启中…' : '重启 Host' }}</button></div></div>
|
||||
<div v-if="host" class="status-grid">
|
||||
<div><span>状态</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div>
|
||||
<div><span>服务</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div>
|
||||
<div><span>协议版本</span><strong>{{ host.protocol_version || '—' }}</strong></div>
|
||||
<div><span>工具数量</span><strong>{{ host.tools_count }}</strong></div>
|
||||
<div><span>启动时间</span><strong>{{ formatTime(host.started_at) }}</strong></div>
|
||||
<div><span>最后心跳</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="empty-state">正在读取 Host 状态…</div>
|
||||
<div v-if="host?.error" class="error-banner host-error">{{ host.error }}</div>
|
||||
<p class="security-hint">当前仅运行插件清单声明的 stdio MCP Server,不开放任意 Shell 命令和环境变量编辑。</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'settings'" class="mcp-section">
|
||||
<div class="section-head"><div><h3>设置与密钥</h3><p>表单由后端 Schema 生成;密钥不会被读取或回显。</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? '保存中…' : '保存普通设置' }}</button></div>
|
||||
<div v-if="schema" class="settings-list">
|
||||
<div v-for="field in schema.fields" :key="field.key" class="setting-row">
|
||||
<div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">必填</em></label><p>{{ field.description || (field.type === 'secret' ? '加密保存,不在页面回显。' : '') }}</p></div>
|
||||
<template v-if="field.type === 'secret'">
|
||||
<div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? '已配置;输入新值可替换' : '输入密钥'" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">安全保存</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">删除</button></div>
|
||||
<span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? '已配置' : '未配置' }}</span>
|
||||
</template>
|
||||
<template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? '开启' : '关闭' }}</label></template>
|
||||
<template v-else-if="field.type === 'select'"><select :id="'plugin-setting-' + field.key" class="select" :value="values[field.key]" @change="updateValue(field, ($event.target as HTMLSelectElement).value)"><option v-for="option in field.options" :key="option" :value="option">{{ option }}</option></select></template>
|
||||
<template v-else><input :id="'plugin-setting-' + field.key" class="input" :type="field.type === 'number' ? 'number' : 'text'" :min="field.minimum ?? undefined" :max="field.maximum ?? undefined" :required="field.required" :value="values[field.key] ?? ''" @input="updateValue(field, ($event.target as HTMLInputElement).value)"></template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="empty-state">正在读取 Plugin 设置…</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mcp-section">
|
||||
<div class="section-head"><div><h3>Plugin 命令</h3><p>执行该 Plugin 注册的受控 Command Contribution。</p></div><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button></div>
|
||||
<div v-if="commands.length" class="command-list">
|
||||
<article v-for="command in commands" :key="command.command_id" class="item-card command-card">
|
||||
<div class="command-head"><div><strong>{{ command.title }}</strong><p>{{ command.description || command.command_id }}</p></div><span class="badge" :class="{ success: commandAvailable(command), warning: command.enabled && !commandAvailable(command) }">{{ commandAvailable(command) ? '可执行' : command.enabled ? '缺少上下文' : '不可用' }}</span></div>
|
||||
<div v-if="Object.keys(properties(command)).length" class="command-fields">
|
||||
<label v-for="(definition, key) in properties(command)" :key="key" class="field"><span>{{ String(definition.title || key) }}<em v-if="required(command, key)">必填</em></span><select v-if="Array.isArray(definition.enum)" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="">请选择</option><option v-for="option in definition.enum" :key="String(option)" :value="String(option)">{{ option }}</option></select><select v-else-if="definition.type === 'boolean'" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="false">否</option><option value="true">是</option></select><input v-else class="input" :type="definition.type === 'number' || definition.type === 'integer' ? 'number' : 'text'" @input="updateArgument(command.command_id, key, ($event.target as HTMLInputElement).value, definition)"></label>
|
||||
</div>
|
||||
<button class="button-primary command-run" :disabled="!commandAvailable(command) || busy === command.command_id" @click="execute(command)"><AppIcon :icon="VideoPlay" :size="15" />{{ busy === command.command_id ? '执行中…' : '执行命令' }}</button>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state"><div><strong>没有可用命令</strong><p>启用 Plugin 后,已注册的命令会出现在这里。</p></div></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mcp-panel { margin-top: var(--space-xl); padding-top: var(--space-xl); border-top: 1px solid var(--color-border-default); }
|
||||
.mcp-tabs { display: flex; gap: var(--space-xs); margin-bottom: var(--space-xl); padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-background-secondary); }
|
||||
.mcp-tabs button { padding: 9px var(--space-md); border-radius: var(--radius-md); color: var(--color-text-secondary); }
|
||||
.mcp-tabs button:hover { background: var(--color-background-hover); }
|
||||
.mcp-tabs button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
|
||||
.mcp-section { min-height: 220px; }
|
||||
.section-head, .command-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); margin-bottom: var(--space-lg); }
|
||||
.section-head p, .command-head p { margin-top: var(--space-xs); color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
|
||||
.section-head button, .command-run { display: inline-flex; align-items: center; gap: var(--space-xs); }
|
||||
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(165px, 1fr)); gap: var(--space-sm); }
|
||||
.status-grid > div { display: grid; gap: var(--space-xs); padding: var(--space-md); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||
.status-grid span { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
.status-grid strong { display: flex; align-items: center; gap: var(--space-xs); font-size: var(--font-size-sm); }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-text-tertiary); }
|
||||
.status-dot.ready { background: var(--color-success); box-shadow: 0 0 0 4px var(--color-success-soft); }
|
||||
.status-dot.error, .status-dot.unhealthy { background: var(--color-error); box-shadow: 0 0 0 4px var(--color-error-soft); }
|
||||
.status-dot.starting { background: var(--color-warning); box-shadow: 0 0 0 4px var(--color-warning-soft); }
|
||||
.security-hint { margin-top: var(--space-lg); padding: var(--space-md); border-left: 3px solid var(--color-info); background: var(--color-info-soft); color: var(--color-text-secondary); font-size: var(--font-size-sm); }
|
||||
.host-error { margin-top: var(--space-lg); }
|
||||
.settings-list { display: grid; }
|
||||
.setting-row { display: grid; grid-template-columns: minmax(180px, .9fr) minmax(260px, 1.1fr) auto; align-items: center; gap: var(--space-lg); padding: var(--space-lg) 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.field-copy label { display: flex; align-items: center; gap: var(--space-xs); font-weight: 650; }
|
||||
.field-copy p { margin-top: var(--space-xs); color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
|
||||
em { margin-left: var(--space-xs); color: var(--color-error); font-size: var(--font-size-xs); font-style: normal; }
|
||||
.secret-control { display: flex; gap: var(--space-xs); }
|
||||
.secret-state { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
.secret-state.configured { color: var(--color-success); }
|
||||
.check-control { display: flex; align-items: center; gap: var(--space-sm); color: var(--color-text-secondary); }
|
||||
.check-control input { width: 18px; height: 18px; accent-color: var(--color-accent-primary); }
|
||||
.command-list, .command-card { display: grid; gap: var(--space-sm); }
|
||||
.command-card:hover { transform: none; }
|
||||
.command-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
|
||||
.command-run { justify-self: end; }
|
||||
@media (max-width: 800px) { .mcp-tabs { overflow-x: auto; } .mcp-tabs button { flex: 0 0 auto; } .setting-row { grid-template-columns: 1fr; gap: var(--space-sm); } .secret-control { flex-wrap: wrap; } }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import PluginMcpPanel from './PluginMcpPanel.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
|
||||
@@ -16,7 +17,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>Plugin 管理</h1><p>管理插件生命周期、权限和受控 Contribution。</p></div><button class="button-primary" @click="install">安装 Plugin</button></header>
|
||||
<header class="feature-header"><div><h1>Plugin 与 MCP</h1><p>管理插件生命周期、MCP Host、权限和受控 Contribution。</p></div><button class="button-primary" @click="install">安装 Plugin</button></header>
|
||||
<div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
|
||||
<div v-if="pluginStore.selectedPlugin" class="panel">
|
||||
<div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">授权权限</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">卸载</button></div></div>
|
||||
@@ -24,6 +25,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
|
||||
<div class="detail-grid"><div><h3>权限</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div>
|
||||
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div>
|
||||
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill:{{ pluginStore.selectedPlugin.dependent_skills.join('、') }}</div>
|
||||
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
|
||||
</div>
|
||||
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} 项 Contribution</p></article></div>
|
||||
</section>
|
||||
|
||||
@@ -22,7 +22,7 @@ async function waitForPath(path: string) {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.spyOn(workspaceService, 'openVault').mockResolvedValue({ path: 'C:/vault', name: 'vault' })
|
||||
vi.spyOn(workspaceService, 'openVault').mockResolvedValue({ vault_id: 'default', path: 'C:/vault', name: 'vault' })
|
||||
vi.spyOn(workspaceService, 'getFileTree').mockResolvedValue([
|
||||
{
|
||||
id: 'folder-data', name: '数据结构', path: '/数据结构', type: 'folder', is_open: true,
|
||||
@@ -35,6 +35,9 @@ beforeEach(() => {
|
||||
vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (path) =>
|
||||
path.includes('红黑树') ? '# 红黑树\n' : '# 二叉搜索树\n'
|
||||
)
|
||||
vi.spyOn(workspaceService, 'getNoteId').mockImplementation(async (path) =>
|
||||
path.includes('红黑树') ? 'note-rbt' : 'note-bst'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -63,10 +66,12 @@ describe('FileTreePanel file switching', () => {
|
||||
await waitForPath('/数据结构/红黑树.md')
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(editorStore.content).toContain('# 红黑树')
|
||||
expect(editorStore.currentNoteId).toBe('note-rbt')
|
||||
|
||||
await findNode('二叉搜索树.md').trigger('click')
|
||||
await waitForPath('/数据结构/二叉搜索树.md')
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/二叉搜索树.md')
|
||||
expect(editorStore.content).toContain('# 二叉搜索树')
|
||||
expect(editorStore.currentNoteId).toBe('note-bst')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ const routes = [
|
||||
path: '/extensions/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('@/features/plugins/PluginsView.vue'),
|
||||
meta: { title: 'Plugin 管理', requiresVault: true },
|
||||
meta: { title: 'Plugin 与 MCP', requiresVault: true },
|
||||
},
|
||||
{
|
||||
path: '/themes',
|
||||
@@ -69,17 +69,15 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach((to) => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
if (to.meta.requiresVault && !workspaceStore.hasVault) {
|
||||
next({ path: '/' })
|
||||
return
|
||||
return { path: '/' }
|
||||
}
|
||||
if (to.path === '/' && workspaceStore.hasVault) {
|
||||
next({ path: '/workspace' })
|
||||
return
|
||||
return { path: '/workspace' }
|
||||
}
|
||||
next()
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('workspaceService backend adapter', () => {
|
||||
const markdown = await workspaceService.readFileContent('/课程/操作系统.md')
|
||||
await workspaceService.saveFileContent('/课程/操作系统.md', '# 已更新\n')
|
||||
|
||||
expect(vault).toEqual({ path: 'C:\\data\\vault', name: 'vault' })
|
||||
expect(vault).toEqual({ vault_id: 'default', path: 'C:\\data\\vault', name: 'vault' })
|
||||
expect(tree[0].children?.[0]).toMatchObject({
|
||||
id: 'note-os', note_id: 'note-os', path: '/课程/操作系统.md', type: 'file',
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as noteService from './noteService'
|
||||
|
||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||
export interface VaultInfo {
|
||||
vault_id: string
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
@@ -81,13 +82,17 @@ export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
|
||||
|
||||
export async function getRecentVaults(): Promise<VaultInfo[]> {
|
||||
const workspace = await getWorkspaceInfo()
|
||||
return [{ path: workspace.path, name: workspace.name }]
|
||||
return [{ vault_id: workspace.vault_id, path: workspace.path, name: workspace.name }]
|
||||
}
|
||||
|
||||
export async function openVault(path: string): Promise<VaultInfo> {
|
||||
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
|
||||
cacheEntries(snapshot.items)
|
||||
return { path: snapshot.workspace.path, name: snapshot.workspace.name }
|
||||
return {
|
||||
vault_id: snapshot.workspace.vault_id,
|
||||
path: snapshot.workspace.path,
|
||||
name: snapshot.workspace.name,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createVault(path: string, name: string): Promise<VaultInfo> {
|
||||
@@ -110,6 +115,11 @@ export async function readFileContent(filePath: string): Promise<string> {
|
||||
return note.markdown
|
||||
}
|
||||
|
||||
/** Resolve the backend note identity already associated with a workspace path. */
|
||||
export async function getNoteId(filePath: string): Promise<string> {
|
||||
return requireNoteId(filePath)
|
||||
}
|
||||
|
||||
export async function saveFileContent(filePath: string, content: string): Promise<void> {
|
||||
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
|
||||
}
|
||||
|
||||
@@ -88,9 +88,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const previousStatus = saveStatus.value
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
const loadedContent = await workspaceService.readFileContent(filePath)
|
||||
const [loadedContent, loadedNoteId] = await Promise.all([
|
||||
workspaceService.readFileContent(filePath),
|
||||
workspaceService.getNoteId(filePath),
|
||||
])
|
||||
if (version !== loadVersion) return
|
||||
currentFilePath.value = filePath
|
||||
currentNoteId.value = loadedNoteId
|
||||
content.value = loadedContent
|
||||
saveStatus.value = 'saved'
|
||||
lastSavedAt.value = new Date().toISOString()
|
||||
|
||||
@@ -5,13 +5,14 @@ import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const vaultPath = ref('')
|
||||
const vaultId = ref('')
|
||||
const vaultName = ref('')
|
||||
const fileTree = ref<FileNode[]>([])
|
||||
const openFiles = ref<string[]>([])
|
||||
const activeFilePath = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const hasVault = ref(false)
|
||||
const recentVaults = ref<{ path: string; name: string }[]>([])
|
||||
const recentVaults = ref<workspaceService.VaultInfo[]>([])
|
||||
|
||||
const activeFile = computed(() => {
|
||||
if (!activeFilePath.value) return null
|
||||
@@ -66,6 +67,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
try {
|
||||
const info = await workspaceService.openVault(path)
|
||||
vaultPath.value = info.path
|
||||
vaultId.value = info.vault_id
|
||||
vaultName.value = info.name
|
||||
fileTree.value = await workspaceService.getFileTree()
|
||||
hasVault.value = true
|
||||
@@ -80,6 +82,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
try {
|
||||
const info = await workspaceService.createVault(path, name)
|
||||
vaultPath.value = info.path
|
||||
vaultId.value = info.vault_id
|
||||
vaultName.value = info.name
|
||||
fileTree.value = await workspaceService.getFileTree()
|
||||
hasVault.value = true
|
||||
@@ -144,6 +147,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
|
||||
return {
|
||||
vaultPath,
|
||||
vaultId,
|
||||
vaultName,
|
||||
fileTree,
|
||||
openFiles,
|
||||
|
||||
Reference in New Issue
Block a user