diff --git a/README.md b/README.md index a8b7637..cd85754 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 136 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 136 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index 2458a4f..bb1ddf9 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md @@ -2329,7 +2329,7 @@ Markdown Workspace 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 -截至 2026-09-02,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host 以及 Plugin Command/Settings 后端 Contract 与前端 Service 也已完成。当前验证基线为后端 136 项测试、前端 29 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 +截至 2026-09-03,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host,以及 Plugin Command/Settings 前后端闭环也已完成。Plugin 详情页现已提供 Host 状态、重启、动态设置、Secret 管理和命令执行,全局命令面板可加载 Plugin Command。当前验证基线为后端 136 项测试、前端 32 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 第二阶段在既有 Contract 上接入: diff --git a/docs/architecture/第二阶段团队分工表.md b/docs/architecture/第二阶段团队分工表.md index 0e229b5..3ae6cc9 100644 --- a/docs/architecture/第二阶段团队分工表.md +++ b/docs/architecture/第二阶段团队分工表.md @@ -6,9 +6,9 @@ 第二阶段继续保持第一阶段的模块 ownership: -- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、整体架构与代码审阅。 +- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、Plugin Command / Settings UI、整体架构与代码审阅。 - 杨星萱:Knowledge Core、Retrieval Core、Benchmark、文档导出、函数图像绘制。 -- 吉海燕:Frontend、Theme、Agent Trace、Plugin UI Contribution、Mermaid 渲染。 +- 吉海燕:Frontend、Theme、Agent Trace、Mermaid 渲染。 --- @@ -16,9 +16,9 @@ | 成员 | 主要负责方向 | 第二阶段任务 | 配合事项 | | --- | --- | --- | --- | -| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command Contribution 后端、Plugin Settings Contribution 后端、更多 Provider、整体集成、代码审阅与统筹 | 与吉海燕联调 Plugin Command / Settings 前端;为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 | +| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command / Settings 前后端闭环、更多 Provider、整体集成、代码审阅与统筹 | 为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 | | 杨星萱 | Knowledge Core / Retrieval Core / Benchmark / Export / 数学内容渲染 | RAG Benchmark、Agent Benchmark 基础设施、Markdown → HTML / PDF / DOCX、函数图像绘制与渲染支持、Retrieval 调优 | 与范涵宇确认 Agent Benchmark 事件和测试数据结构;与吉海燕联调函数图像在编辑器和预览区中的展示 | -| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Plugin Command / Settings 前端、Mermaid 渲染支持 | 与范涵宇联调 Plugin Contribution Contract 与 AgentEvent;与杨星萱联调函数图像及导出预览 | +| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Mermaid 渲染支持 | 与范涵宇联调 AgentEvent;与杨星萱联调函数图像及导出预览 | --- @@ -384,7 +384,7 @@ Metadata Filter --- -## 五、吉海燕 +## 五、前端展示层(吉海燕;5.4—5.5 由范涵宇负责) ### 5.1 Theme Import @@ -488,7 +488,7 @@ Error - 用户取消; - Citation 跳转。 -### 5.4 Plugin Command 前端 +### 5.4 Plugin Command 前端(范涵宇) 负责 Command Contribution 在前端呈现。 @@ -502,7 +502,7 @@ Toolbar Action 前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。 -### 5.5 Plugin Settings 前端 +### 5.5 Plugin Settings 前端(范涵宇) 根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。 @@ -572,9 +572,9 @@ Mermaid 渲染需要与 Theme Design Token 联动。 | --- | --- | --- | | MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 | | Plugin Command Runtime | 范涵宇 | 吉海燕 | -| Plugin Command UI | 吉海燕 | 范涵宇 | +| Plugin Command UI | 范涵宇 | 吉海燕 | | Plugin Settings Runtime | 范涵宇 | 吉海燕 | -| Plugin Settings UI | 吉海燕 | 范涵宇 | +| Plugin Settings UI | 范涵宇 | 吉海燕 | | Agent Benchmark Framework | 杨星萱 | 范涵宇 | | Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 | | Agent Trace Visualization | 吉海燕 | 范涵宇 | @@ -750,6 +750,8 @@ Markdown - [x] Agent 能调用 MCP Tool; - [x] Plugin Command Contribution 后端可注册; - [x] Plugin Settings Contribution 后端可解析; +- [x] Plugin Command 可以显示并从前端执行; +- [x] Plugin Settings 可以动态生成设置项并独立提交 Secret; - [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定; - [ ] 完成跨模块接口审阅和第二阶段集成。 @@ -773,8 +775,6 @@ Markdown - [ ] Theme 可以启用、停用和卸载; - [ ] Agent Trace 可以展示完整 Tool Call 顺序; - [ ] Trace Node 可以查看参数、结果、耗时和错误; -- [ ] Plugin Command 可以显示在前端; -- [ ] Plugin Settings 可以动态生成设置项; - [ ] Markdown Mermaid Code Block 可以渲染; - [ ] Mermaid 支持主题切换; - [ ] Mermaid 渲染错误可以明确展示; @@ -791,6 +791,8 @@ Markdown ├── MCP Bridge ├── Plugin Command Runtime ├── Plugin Settings Runtime +├── Plugin Command UI +├── Plugin Settings UI ├── Provider Adapter ├── Code Review └── Integration / Coordination @@ -812,8 +814,6 @@ Markdown ├── Theme Import ├── Theme Community Format ├── Agent Trace Visualization -├── Plugin Command UI -├── Plugin Settings UI └── Mermaid ├── Markdown Rendering ├── Theme Adaptation diff --git a/docs/development/Plugin-Command与Settings开发说明.md b/docs/development/Plugin-Command与Settings开发说明.md index cec644e..4de8a81 100644 --- a/docs/development/Plugin-Command与Settings开发说明.md +++ b/docs/development/Plugin-Command与Settings开发说明.md @@ -1,6 +1,6 @@ # Plugin Command 与 Settings 开发说明 -> 更新日期:2026-09-02。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界和前端 Service Contract。当前回归基线为后端 136 项测试、前端 29 项测试,TypeScript 类型检查和生产构建通过。 +> 更新日期:2026-09-03。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界及前端闭环。当前回归基线为后端 136 项测试、前端 32 项测试,TypeScript 类型检查和生产构建通过。 ## 1. 阶段目标 @@ -8,9 +8,9 @@ - Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect; - Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用; -- Frontend Contract:提供稳定的 TypeScript DTO 与 Service,供后续命令面板、右键菜单和插件设置页直接联调。 +- Frontend:Plugin 详情页提供 Host 状态、动态设置、Secret 管理与命令执行,全局命令面板加载 command_palette Contribution。 -本阶段不实现前端页面,也不把第三方代码导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。 +第三方代码不会导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。 ## 2. 包内声明 @@ -86,7 +86,9 @@ PUT /api/plugins/{plugin_id}/settings/{key}/secret DELETE /api/plugins/{plugin_id}/settings/{key}/secret ``` -前端 `pluginService` 已提供对应方法及 Wire DTO,但阶段 D 不创建命令面板或动态设置表单页面。调用方必须使用服务层,不自行拼接路径;Secret 不得写入 Pinia、LocalStorage 或调试日志。 +前端 `pluginService` 提供对应方法及 Wire DTO,Plugin 详情页据此展示 MCP Host 状态和重启入口、动态生成五类设置字段、独立写入或删除 Secret,并执行带参数的 Plugin Command。全局命令面板打开时获取 `command_palette` 命令;需要必填参数的命令会引导用户进入详情页填写。调用方必须使用服务层,不自行拼接路径。 + +Secret 明文仅存在于当前密码输入框绑定的组件内存,提交后立即清空;不得写入 Pinia、LocalStorage、普通 Settings 请求或调试日志。前端不会读取 Secret 明文,只展示后端返回的 `configured` 状态。 ## 6. 主要错误边界 @@ -110,6 +112,6 @@ pnpm type-check pnpm build ``` -阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径和前端 Service 请求格式。 +阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径、前端 Service 请求格式、Host 状态展示和动态 Secret 表单。 生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。 diff --git a/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md b/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md index 8ea1985..0c6ccf8 100644 --- a/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md +++ b/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md @@ -453,7 +453,7 @@ git diff --check passed - 阶段 C.5 按规划放在第二阶段功能与测试完成后、第三阶段桌面集成正式构建 Tauri/Rust 沙箱之前; - 生产环境继续通过配置门禁拒绝未沙箱化的 MCP Host; - 桌面端将 Plugin Secret 从开发期 Fernet 文件迁移到 Stronghold 或系统 Keychain,保持现有引用和 HTTP Contract; -- 前端后续实现命令面板、上下文菜单和动态 Settings 表单,继续复用现有 Service; +- 前端已实现命令面板、Plugin 详情命令和动态 Settings / Secret 表单;上下文菜单与 Toolbar 挂载点仍复用现有 Service 继续扩展; - Command 审计当前是 500 条有界内存队列,长期审计持久化需在后续阶段单独设计; - 前端大 Chunk 应通过路由和 Markdown 依赖拆包处理,不与本次 Extension Contract 修改混合。 diff --git a/frontend/src/components/common/CommandPalette.spec.ts b/frontend/src/components/common/CommandPalette.spec.ts new file mode 100644 index 0000000..114cd32 --- /dev/null +++ b/frontend/src/components/common/CommandPalette.spec.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createMemoryHistory, createRouter } from 'vue-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as pluginService from '@/services/pluginService' +import { useEditorStore } from '@/stores/editor' +import { useWorkspaceStore } from '@/stores/workspace' +import CommandPalette from './CommandPalette.vue' + +vi.mock('@/services/pluginService', async (loadOriginal) => { + const original = await loadOriginal() + return { ...original, listPluginCommands: vi.fn(), executePluginCommand: vi.fn() } +}) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(pluginService.listPluginCommands).mockResolvedValue([{ + command_id: 'demo.selection', + plugin_id: 'demo', + title: '处理选区', + description: '', + icon: null, + locations: ['command_palette'], + when: ['workspace.has_vault', 'editor.has_note', 'editor.has_selection'], + parameters: { type: 'object', properties: {}, additionalProperties: false }, + enabled: true, + }]) + vi.mocked(pluginService.executePluginCommand).mockResolvedValue({ + command_id: 'demo.selection', + status: 'completed', + effect: { type: 'notification', payload: { level: 'success', message: '完成' } }, + }) +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('CommandPalette Plugin Command', () => { + it('filters by when context and sends stable backend identities plus the captured selection', async () => { + const workspace = useWorkspaceStore() + workspace.hasVault = true + workspace.vaultId = 'vault-default' + const editor = useEditorStore() + editor.currentNoteId = 'note-1' + editor.currentFilePath = '/note.md' + + vi.spyOn(window, 'getSelection').mockReturnValue({ + toString: () => 'selected text', + } as Selection) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }) + await router.push('/') + const wrapper = mount(CommandPalette, { attachTo: document.body, global: { plugins: [router] } }) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true })) + await flushPromises() + const command = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('处理选区')) + expect(command).toBeTruthy() + command!.click() + await flushPromises() + + expect(pluginService.executePluginCommand).toHaveBeenCalledWith('demo.selection', {}, { + vault_id: 'vault-default', + note_id: 'note-1', + file_path: '/note.md', + selection: 'selected text', + }) + expect(document.body.textContent).toContain('完成') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue index 9ab6aa5..b5a99f6 100644 --- a/frontend/src/components/common/CommandPalette.vue +++ b/frontend/src/components/common/CommandPalette.vue @@ -5,18 +5,26 @@ import { useEditorStore } from '@/stores/editor' import { useThemeStore } from '@/stores/theme' import { useWorkspaceStore } from '@/stores/workspace' import * as workspaceService from '@/services/workspaceService' +import * as pluginService from '@/services/pluginService' +import type { PluginCommand, PluginCommandEffect } from '@/contracts' +import { usePluginStore } from '@/stores/plugin' const router = useRouter() const editorStore = useEditorStore() const themeStore = useThemeStore() const workspaceStore = useWorkspaceStore() +const pluginStore = usePluginStore() const open = ref(false) const query = ref('') const input = ref(null) +const pluginCommands = ref([]) +const commandError = ref('') +const commandNotice = ref('') +const selectionSnapshot = ref(null) interface Command { id: string; label: string; hint: string; run: () => void | Promise } -const commands = computed(() => [ +const builtinCommands = computed(() => [ { id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') }, { id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') }, { id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') }, @@ -28,14 +36,37 @@ const commands = computed(() => [ { id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote }, ]) +const commands = computed(() => [ + ...builtinCommands.value, + ...pluginCommands.value.filter(isPluginCommandAvailable).map((command) => ({ + id: 'plugin:' + command.command_id, + label: command.title, + hint: 'Plugin · ' + command.plugin_id, + run: () => executePluginCommand(command), + })), +]) + +function isPluginCommandAvailable(command: PluginCommand) { + if (!command.enabled) return false + return command.when.every((condition) => { + if (condition === 'workspace.has_vault') return Boolean(workspaceStore.vaultId) + if (condition === 'editor.has_note') return Boolean(editorStore.currentNoteId) + if (condition === 'editor.has_selection') return Boolean(selectionSnapshot.value) + return false + }) +} + const filteredCommands = computed(() => { const value = query.value.trim().toLocaleLowerCase() return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value }) function show() { + selectionSnapshot.value = window.getSelection()?.toString() || null open.value = true query.value = '' + commandError.value = '' + void loadPluginCommands() void nextTick(() => input.value?.focus()) } @@ -44,7 +75,11 @@ function hide() { open.value = false } async function execute(command: Command | undefined) { if (!command) return hide() - await command.run() + try { + await command.run() + } catch (error) { + commandNotice.value = error instanceof Error ? error.message : '命令执行失败' + } } async function createNote() { @@ -58,6 +93,55 @@ async function createNote() { await router.push('/workspace') } +async function loadPluginCommands() { + try { + pluginCommands.value = await pluginService.listPluginCommands('command_palette') + } catch (error) { + commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败' + } +} + +function hasRequiredArguments(command: PluginCommand) { + return Array.isArray(command.parameters.required) && command.parameters.required.length > 0 +} + +async function executePluginCommand(command: PluginCommand) { + if (hasRequiredArguments(command)) { + pluginStore.selectPlugin(command.plugin_id) + await router.push('/extensions/plugins') + commandNotice.value = '请在 Plugin 详情页填写参数后执行“' + command.title + '”。' + return + } + const result = await pluginService.executePluginCommand(command.command_id, {}, { + vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null, + note_id: editorStore.currentNoteId, + file_path: editorStore.currentFilePath, + selection: selectionSnapshot.value, + }) + await applyPluginEffect(result.effect) +} + +async function applyPluginEffect(effect: PluginCommandEffect) { + if (effect.type === 'notification') { commandNotice.value = effect.payload.message; return } + if (effect.type === 'navigate') { + const routes: Record = { + 'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat', + agent: '/agent/runs', tasks: '/tasks', skills: '/extensions/skills', + plugins: '/extensions/plugins', themes: '/themes', settings: '/settings', + } + await router.push(routes[effect.payload.route]) + return + } + if (effect.type === 'refresh') { + if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins() + if (effect.payload.scope === 'commands') await loadPluginCommands() + commandNotice.value = '相关数据已刷新。' + return + } + if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return } + commandNotice.value = 'Plugin 命令执行完成。' +} + function handleKeydown(event: KeyboardEvent) { if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') { event.preventDefault() @@ -72,10 +156,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))