feat(frontend): complete MCP plugin management UI
This commit is contained in:
@@ -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,供后续命令面板、右键菜单和插件设置页直接联调。
|
- Frontend:Plugin 详情页提供 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 DTO,Plugin 详情页据此展示 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); } }
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user