feat(frontend): complete MCP plugin management UI

This commit is contained in:
2026-09-03 14:22:29 +08:00
parent 1e32b2e0f4
commit ed2e867db1
18 changed files with 618 additions and 38 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 136 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 136 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。 构建产物位于 `frontend/dist`,该目录不提交到 Git。
@@ -2329,7 +2329,7 @@ Markdown Workspace
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 第一阶段 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 上接入: 第二阶段在既有 Contract 上接入:
@@ -6,9 +6,9 @@
第二阶段继续保持第一阶段的模块 ownership: 第二阶段继续保持第一阶段的模块 ownership:
- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、整体架构与代码审阅。 - 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、Plugin Command / Settings UI、整体架构与代码审阅。
- 杨星萱:Knowledge Core、Retrieval Core、Benchmark、文档导出、函数图像绘制。 - 杨星萱: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 事件和测试数据结构;与吉海燕联调函数图像在编辑器和预览区中的展示 | | 杨星萱 | 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 ### 5.1 Theme Import
@@ -488,7 +488,7 @@ Error
- 用户取消; - 用户取消;
- Citation 跳转。 - Citation 跳转。
### 5.4 Plugin Command 前端 ### 5.4 Plugin Command 前端(范涵宇)
负责 Command Contribution 在前端呈现。 负责 Command Contribution 在前端呈现。
@@ -502,7 +502,7 @@ Toolbar Action
前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。 前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。
### 5.5 Plugin Settings 前端 ### 5.5 Plugin Settings 前端(范涵宇)
根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。 根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。
@@ -572,9 +572,9 @@ Mermaid 渲染需要与 Theme Design Token 联动。
| --- | --- | --- | | --- | --- | --- |
| MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 | | MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 |
| Plugin Command Runtime | 范涵宇 | 吉海燕 | | Plugin Command Runtime | 范涵宇 | 吉海燕 |
| Plugin Command UI | 吉海燕 | 范涵宇 | | Plugin Command UI | 范涵宇 | 吉海燕 |
| Plugin Settings Runtime | 范涵宇 | 吉海燕 | | Plugin Settings Runtime | 范涵宇 | 吉海燕 |
| Plugin Settings UI | 吉海燕 | 范涵宇 | | Plugin Settings UI | 范涵宇 | 吉海燕 |
| Agent Benchmark Framework | 杨星萱 | 范涵宇 | | Agent Benchmark Framework | 杨星萱 | 范涵宇 |
| Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 | | Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 |
| Agent Trace Visualization | 吉海燕 | 范涵宇 | | Agent Trace Visualization | 吉海燕 | 范涵宇 |
@@ -750,6 +750,8 @@ Markdown
- [x] Agent 能调用 MCP Tool - [x] Agent 能调用 MCP Tool
- [x] Plugin Command Contribution 后端可注册; - [x] Plugin Command Contribution 后端可注册;
- [x] Plugin Settings Contribution 后端可解析; - [x] Plugin Settings Contribution 后端可解析;
- [x] Plugin Command 可以显示并从前端执行;
- [x] Plugin Settings 可以动态生成设置项并独立提交 Secret;
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定; - [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
- [ ] 完成跨模块接口审阅和第二阶段集成。 - [ ] 完成跨模块接口审阅和第二阶段集成。
@@ -773,8 +775,6 @@ Markdown
- [ ] Theme 可以启用、停用和卸载; - [ ] Theme 可以启用、停用和卸载;
- [ ] Agent Trace 可以展示完整 Tool Call 顺序; - [ ] Agent Trace 可以展示完整 Tool Call 顺序;
- [ ] Trace Node 可以查看参数、结果、耗时和错误; - [ ] Trace Node 可以查看参数、结果、耗时和错误;
- [ ] Plugin Command 可以显示在前端;
- [ ] Plugin Settings 可以动态生成设置项;
- [ ] Markdown Mermaid Code Block 可以渲染; - [ ] Markdown Mermaid Code Block 可以渲染;
- [ ] Mermaid 支持主题切换; - [ ] Mermaid 支持主题切换;
- [ ] Mermaid 渲染错误可以明确展示; - [ ] Mermaid 渲染错误可以明确展示;
@@ -791,6 +791,8 @@ Markdown
├── MCP Bridge ├── MCP Bridge
├── Plugin Command Runtime ├── Plugin Command Runtime
├── Plugin Settings Runtime ├── Plugin Settings Runtime
├── Plugin Command UI
├── Plugin Settings UI
├── Provider Adapter ├── Provider Adapter
├── Code Review ├── Code Review
└── Integration / Coordination └── Integration / Coordination
@@ -812,8 +814,6 @@ Markdown
├── Theme Import ├── Theme Import
├── Theme Community Format ├── Theme Community Format
├── Agent Trace Visualization ├── Agent Trace Visualization
├── Plugin Command UI
├── Plugin Settings UI
└── Mermaid └── Mermaid
├── Markdown Rendering ├── Markdown Rendering
├── Theme Adaptation ├── Theme Adaptation
@@ -1,6 +1,6 @@
# Plugin Command 与 Settings 开发说明 # 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. 阶段目标 ## 1. 阶段目标
@@ -8,9 +8,9 @@
- Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect; - Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect;
- Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用; - Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用;
- Frontend Contract:提供稳定的 TypeScript DTO 与 Service,供后续命令面板、右键菜单和插件设置页直接联调 - FrontendPlugin 详情页提供 Host 状态、动态设置、Secret 管理与命令执行,全局命令面板加载 command_palette Contribution
本阶段不实现前端页面,也不把第三方代码导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。 第三方代码不会导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。
## 2. 包内声明 ## 2. 包内声明
@@ -86,7 +86,9 @@ PUT /api/plugins/{plugin_id}/settings/{key}/secret
DELETE /api/plugins/{plugin_id}/settings/{key}/secret DELETE /api/plugins/{plugin_id}/settings/{key}/secret
``` ```
前端 `pluginService` 提供对应方法及 Wire DTO但阶段 D 不创建命令面板或动态设置表单页面。调用方必须使用服务层,不自行拼接路径;Secret 不得写入 Pinia、LocalStorage 或调试日志 前端 `pluginService` 提供对应方法及 Wire DTOPlugin 详情页据此展示 MCP Host 状态和重启入口、动态生成五类设置字段、独立写入或删除 Secret,并执行带参数的 Plugin Command。全局命令面板打开时获取 `command_palette` 命令;需要必填参数的命令会引导用户进入详情页填写。调用方必须使用服务层,不自行拼接路径
Secret 明文仅存在于当前密码输入框绑定的组件内存,提交后立即清空;不得写入 Pinia、LocalStorage、普通 Settings 请求或调试日志。前端不会读取 Secret 明文,只展示后端返回的 `configured` 状态。
## 6. 主要错误边界 ## 6. 主要错误边界
@@ -110,6 +112,6 @@ pnpm type-check
pnpm build 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 依赖拆包的后续性能任务。 生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。
@@ -453,7 +453,7 @@ git diff --check passed
- 阶段 C.5 按规划放在第二阶段功能与测试完成后、第三阶段桌面集成正式构建 Tauri/Rust 沙箱之前; - 阶段 C.5 按规划放在第二阶段功能与测试完成后、第三阶段桌面集成正式构建 Tauri/Rust 沙箱之前;
- 生产环境继续通过配置门禁拒绝未沙箱化的 MCP Host; - 生产环境继续通过配置门禁拒绝未沙箱化的 MCP Host;
- 桌面端将 Plugin Secret 从开发期 Fernet 文件迁移到 Stronghold 或系统 Keychain,保持现有引用和 HTTP Contract - 桌面端将 Plugin Secret 从开发期 Fernet 文件迁移到 Stronghold 或系统 Keychain,保持现有引用和 HTTP Contract
- 前端后续实现命令面板、上下文菜单和动态 Settings 表单,继续复用现有 Service - 前端实现命令面板、Plugin 详情命令和动态 Settings / Secret 表单;上下文菜单与 Toolbar 挂载点仍复用现有 Service 继续扩展
- Command 审计当前是 500 条有界内存队列,长期审计持久化需在后续阶段单独设计; - Command 审计当前是 500 条有界内存队列,长期审计持久化需在后续阶段单独设计;
- 前端大 Chunk 应通过路由和 Markdown 依赖拆包处理,不与本次 Extension Contract 修改混合。 - 前端大 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 { useThemeStore } from '@/stores/theme'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import * as workspaceService from '@/services/workspaceService' 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 router = useRouter()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const pluginStore = usePluginStore()
const open = ref(false) const open = ref(false)
const query = ref('') const query = ref('')
const input = ref<HTMLInputElement | null>(null) 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> } 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: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') }, { id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') }, { id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
@@ -28,14 +36,37 @@ const commands = computed<Command[]>(() => [
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote }, { 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 filteredCommands = computed(() => {
const value = query.value.trim().toLocaleLowerCase() const value = query.value.trim().toLocaleLowerCase()
return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value
}) })
function show() { function show() {
selectionSnapshot.value = window.getSelection()?.toString() || null
open.value = true open.value = true
query.value = '' query.value = ''
commandError.value = ''
void loadPluginCommands()
void nextTick(() => input.value?.focus()) void nextTick(() => input.value?.focus())
} }
@@ -44,7 +75,11 @@ function hide() { open.value = false }
async function execute(command: Command | undefined) { async function execute(command: Command | undefined) {
if (!command) return if (!command) return
hide() hide()
await command.run() try {
await command.run()
} catch (error) {
commandNotice.value = error instanceof Error ? error.message : '命令执行失败'
}
} }
async function createNote() { async function createNote() {
@@ -58,6 +93,55 @@ async function createNote() {
await router.push('/workspace') 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) { function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') { if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
event.preventDefault() event.preventDefault()
@@ -72,10 +156,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script> </script>
<template> <template>
<div v-if="commandNotice" class="command-toast" role="status">
<span>{{ commandNotice }}</span><button aria-label="关闭通知" @click="commandNotice = ''">×</button>
</div>
<Teleport to="body"> <Teleport to="body">
<div v-if="open" class="command-backdrop" @click.self="hide"> <div v-if="open" class="command-backdrop" @click.self="hide">
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板"> <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])" /> <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"> <div class="command-list">
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)"> <button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
<span>{{ command.label }}</span><small>{{ command.hint }}</small> <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 small, .command-list p, footer { color: var(--color-text-tertiary); }
.command-list p { padding: var(--space-xl); text-align: center; } .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); } 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-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); } } @keyframes command-palette-in { from { opacity: 0; transform: translateY(-8px) scale(.99); } to { opacity: 1; transform: translateY(0) scale(1); } }
+1 -1
View File
@@ -21,7 +21,7 @@ const pageTitle = computed(() => {
agent: '智能体执行轨迹', agent: '智能体执行轨迹',
tasks: '任务', tasks: '任务',
skills: 'Skill 管理', skills: 'Skill 管理',
plugins: 'Plugin 管理', plugins: 'Plugin 与 MCP',
themes: '主题管理', themes: '主题管理',
settings: '设置', settings: '设置',
} }
@@ -27,6 +27,9 @@ beforeEach(() => {
if (filePath === '/数据结构/红黑树.md') return '# 红黑树\n\n新的文件内容' if (filePath === '/数据结构/红黑树.md') return '# 红黑树\n\n新的文件内容'
throw new Error(`Unexpected file path: ${filePath}`) throw new Error(`Unexpected file path: ${filePath}`)
}) })
vi.spyOn(workspaceService, 'getNoteId').mockImplementation(async (filePath) =>
filePath.includes('红黑树') ? 'note-rbt' : 'note-welcome'
)
}) })
afterEach(() => { 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"> <script setup lang="ts">
import { Connection } from '@element-plus/icons-vue' import { Connection } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import PluginMcpPanel from './PluginMcpPanel.vue'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { usePluginStore } from '@/stores/plugin' import { usePluginStore } from '@/stores/plugin'
@@ -16,7 +17,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
<template> <template>
<section class="feature-page"> <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.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
<div v-if="pluginStore.selectedPlugin" class="panel"> <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> <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 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.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> <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>
<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> <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> </section>
@@ -22,7 +22,7 @@ async function waitForPath(path: string) {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
setActivePinia(createPinia()) 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([ vi.spyOn(workspaceService, 'getFileTree').mockResolvedValue([
{ {
id: 'folder-data', name: '数据结构', path: '/数据结构', type: 'folder', is_open: true, id: 'folder-data', name: '数据结构', path: '/数据结构', type: 'folder', is_open: true,
@@ -35,6 +35,9 @@ beforeEach(() => {
vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (path) => vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (path) =>
path.includes('红黑树') ? '# 红黑树\n' : '# 二叉搜索树\n' path.includes('红黑树') ? '# 红黑树\n' : '# 二叉搜索树\n'
) )
vi.spyOn(workspaceService, 'getNoteId').mockImplementation(async (path) =>
path.includes('红黑树') ? 'note-rbt' : 'note-bst'
)
}) })
afterEach(() => { afterEach(() => {
@@ -63,10 +66,12 @@ describe('FileTreePanel file switching', () => {
await waitForPath('/数据结构/红黑树.md') await waitForPath('/数据结构/红黑树.md')
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md') expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
expect(editorStore.content).toContain('# 红黑树') expect(editorStore.content).toContain('# 红黑树')
expect(editorStore.currentNoteId).toBe('note-rbt')
await findNode('二叉搜索树.md').trigger('click') await findNode('二叉搜索树.md').trigger('click')
await waitForPath('/数据结构/二叉搜索树.md') await waitForPath('/数据结构/二叉搜索树.md')
expect(workspaceStore.activeFilePath).toBe('/数据结构/二叉搜索树.md') expect(workspaceStore.activeFilePath).toBe('/数据结构/二叉搜索树.md')
expect(editorStore.content).toContain('# 二叉搜索树') expect(editorStore.content).toContain('# 二叉搜索树')
expect(editorStore.currentNoteId).toBe('note-bst')
}) })
}) })
+5 -7
View File
@@ -48,7 +48,7 @@ const routes = [
path: '/extensions/plugins', path: '/extensions/plugins',
name: 'plugins', name: 'plugins',
component: () => import('@/features/plugins/PluginsView.vue'), component: () => import('@/features/plugins/PluginsView.vue'),
meta: { title: 'Plugin 管理', requiresVault: true }, meta: { title: 'Plugin 与 MCP', requiresVault: true },
}, },
{ {
path: '/themes', path: '/themes',
@@ -69,17 +69,15 @@ const router = createRouter({
routes, routes,
}) })
router.beforeEach((to, _from, next) => { router.beforeEach((to) => {
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
if (to.meta.requiresVault && !workspaceStore.hasVault) { if (to.meta.requiresVault && !workspaceStore.hasVault) {
next({ path: '/' }) return { path: '/' }
return
} }
if (to.path === '/' && workspaceStore.hasVault) { if (to.path === '/' && workspaceStore.hasVault) {
next({ path: '/workspace' }) return { path: '/workspace' }
return
} }
next() return true
}) })
router.afterEach((to) => { router.afterEach((to) => {
@@ -73,7 +73,7 @@ describe('workspaceService backend adapter', () => {
const markdown = await workspaceService.readFileContent('/课程/操作系统.md') const markdown = await workspaceService.readFileContent('/课程/操作系统.md')
await workspaceService.saveFileContent('/课程/操作系统.md', '# 已更新\n') 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({ expect(tree[0].children?.[0]).toMatchObject({
id: 'note-os', note_id: 'note-os', path: '/课程/操作系统.md', type: 'file', id: 'note-os', note_id: 'note-os', path: '/课程/操作系统.md', type: 'file',
}) })
+12 -2
View File
@@ -11,6 +11,7 @@ import * as noteService from './noteService'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */ /** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo { export interface VaultInfo {
vault_id: string
path: string path: string
name: string name: string
} }
@@ -81,13 +82,17 @@ export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
export async function getRecentVaults(): Promise<VaultInfo[]> { export async function getRecentVaults(): Promise<VaultInfo[]> {
const workspace = await getWorkspaceInfo() 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> { export async function openVault(path: string): Promise<VaultInfo> {
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }) const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
cacheEntries(snapshot.items) 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> { 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 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> { export async function saveFileContent(filePath: string, content: string): Promise<void> {
await noteService.updateNote(await requireNoteId(filePath), { markdown: content }) await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
} }
+5 -1
View File
@@ -88,9 +88,13 @@ export const useEditorStore = defineStore('editor', () => {
const previousStatus = saveStatus.value const previousStatus = saveStatus.value
saveStatus.value = 'saving' saveStatus.value = 'saving'
try { try {
const loadedContent = await workspaceService.readFileContent(filePath) const [loadedContent, loadedNoteId] = await Promise.all([
workspaceService.readFileContent(filePath),
workspaceService.getNoteId(filePath),
])
if (version !== loadVersion) return if (version !== loadVersion) return
currentFilePath.value = filePath currentFilePath.value = filePath
currentNoteId.value = loadedNoteId
content.value = loadedContent content.value = loadedContent
saveStatus.value = 'saved' saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString() lastSavedAt.value = new Date().toISOString()
+5 -1
View File
@@ -5,13 +5,14 @@ import * as workspaceService from '@/services/workspaceService'
export const useWorkspaceStore = defineStore('workspace', () => { export const useWorkspaceStore = defineStore('workspace', () => {
const vaultPath = ref('') const vaultPath = ref('')
const vaultId = ref('')
const vaultName = ref('') const vaultName = ref('')
const fileTree = ref<FileNode[]>([]) const fileTree = ref<FileNode[]>([])
const openFiles = ref<string[]>([]) const openFiles = ref<string[]>([])
const activeFilePath = ref<string | null>(null) const activeFilePath = ref<string | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const hasVault = ref(false) const hasVault = ref(false)
const recentVaults = ref<{ path: string; name: string }[]>([]) const recentVaults = ref<workspaceService.VaultInfo[]>([])
const activeFile = computed(() => { const activeFile = computed(() => {
if (!activeFilePath.value) return null if (!activeFilePath.value) return null
@@ -66,6 +67,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
try { try {
const info = await workspaceService.openVault(path) const info = await workspaceService.openVault(path)
vaultPath.value = info.path vaultPath.value = info.path
vaultId.value = info.vault_id
vaultName.value = info.name vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree() fileTree.value = await workspaceService.getFileTree()
hasVault.value = true hasVault.value = true
@@ -80,6 +82,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
try { try {
const info = await workspaceService.createVault(path, name) const info = await workspaceService.createVault(path, name)
vaultPath.value = info.path vaultPath.value = info.path
vaultId.value = info.vault_id
vaultName.value = info.name vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree() fileTree.value = await workspaceService.getFileTree()
hasVault.value = true hasVault.value = true
@@ -144,6 +147,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
return { return {
vaultPath, vaultPath,
vaultId,
vaultName, vaultName,
fileTree, fileTree,
openFiles, openFiles,