diff --git a/docs/architecture/agent-scoped-extensions/plan.md b/docs/architecture/agent-scoped-extensions/plan.md deleted file mode 100644 index 048779122..000000000 --- a/docs/architecture/agent-scoped-extensions/plan.md +++ /dev/null @@ -1,110 +0,0 @@ -# Plan: Agent-scoped Plugins, Skills, and MCP - -## Current Findings - -- DeepChat agent 配置已落在 `AgentRepository` 的 `DeepChatAgentConfig` 中,包含模型、system prompt、工具禁用列表、subagent、memory 等。 -- 技能库由 `SkillPresenter` 管理,主目录和启停状态是全局的;会话级 active skills 通过 `conversationId/sessionId` 保存。 -- 运行时构建 prompt/tools 时会读取 `skillPresenter.getMetadataList()`、`getActiveSkills(sessionId)`、`loadSkillContent()`,并按 active skills 注入 prompt。 -- 插件由 `PluginPresenter` 管理,全局安装/启用,插件贡献 MCP server、skills、settings、tool policy 等资源。 -- MCP server 定义与全局 MCP 开关由 `McpPresenter` / `useMcpStore` 管理;`agent_mcp_selections` 当前经 `AcpConfHelper` 退化为 shared selections,主要服务 ACP/shared 语义,不是 DeepChat agent scope。 -- 设置页中技能已有“外部 agent”Tab(Claude/Codex 等技能目录扫描),但这不是 DeepChat agent 级配置。 -- commit `eb81a612df06f93c5407cdf3bbb8c5c6e5fc3a5b` 将 Official Plugins、MCP、Skills 放入主窗口 `/plugins` hub,但其规格明确“不重写 MCP、Skill、Remote、Plugin presenter / 不新增统一持久化表 / 不改变 MCP schema”,所以该提交完成的是入口与视图归集,不是 agent-scoped 数据拆分。 -- 当前临时修复把 `/plugins/skills` 与 `/plugins/mcp` 替换成 `AgentExtensionPolicyPanel`,会丢失添加 skill/server、市场和详情等原有管理能力;需要改为“视图不变、启停语义按 agent”。 - -## Target Model - -Introduce per DeepChat agent extension policy on `DeepChatAgentConfig`: - -```ts -interface DeepChatAgentConfig { - enabledPluginIds?: string[] | null - enabledSkillNames?: string[] | null - enabledMcpServerIds?: string[] | null -} -``` - -Semantics: - -- Built-in `deepchat` stores the source policy. -- Newly created manual DeepChat agents inherit built-in `deepchat` policy through the existing config merge model. -- `null` / `undefined` means inherit from built-in `deepchat` for manual agents; for built-in `deepchat`, it means current compatible global behavior. -- `[]` explicitly disables all plugins / skills / MCP servers for that agent. -- Non-empty array is an allow-list by plugin id / skill name / MCP server id. - -## Implementation Approach - -1. Add typed config fields and normalization/merge support in shared agent types and `AgentRepository`. -2. Add routes/API helpers to read and patch an agent's extension policy through existing `config.updateDeepChatAgent` unless dedicated routes are needed. -3. Update MCP runtime resolution: - - store DeepChat agent MCP policy in `DeepChatAgentConfig.enabledMcpServerIds` or a dedicated DeepChat agent policy table; do not reuse ACP shared selections. - - filter normal MCP tools by session `agentId` before `McpPresenter`/`ToolManager` expose tool definitions. - - include `agentId` and enabled MCP server ids in tool-profile cache fingerprints. - - keep global MCP server definitions, marketplace, install, and global MCP enabled state intact. -4. Update skill runtime resolution: - - derive `agentId` from session runtime state. - - filter skill metadata by agent enabled skill names before presenting ``. - - filter manually active/session active/runtime-activated skill names against the agent allow-list. - - enforce the same allow-list inside skill tools: `skill_list` must hide disabled skills and `skill_view` must reject disabled skill names. - - preserve existing message-level active skills but ignore unavailable skills for prompt/tool profile. -5. Update tool/profile resolution: - - ensure skill-provided tools/scripts are unavailable when their skill is not enabled for the session agent. - - ensure plugin-contributed resources can be filtered by session agent policy. -6. Update plugin handling: - - keep global plugin installation/update/trust as global lifecycle. - - split runtime enablement by DeepChat agent: plugin runtime instances and their contributed resources must be started/stopped for the selected agent scope. - - lifecycle is config-driven by `(agentId, pluginId)` enablement, not session reference-count driven. - - plugin-owned MCP follows plugin enablement: when an agent enables a plugin, that plugin's MCP servers are automatically in-scope for that agent and are not separately selected in the normal MCP selector. - - model runtime identity as `(agentId, pluginId)` so errors/statuses do not collapse across agents. - - expose per-agent enabled/available/runtime status in renderer model. -7. Update UI while preserving existing page layouts: - - `/plugins` can keep a compact plugin policy editor alongside the plugin catalog. - - `/plugins/skills` renders the existing `SkillsSettings` view in `scope="agent"` mode. It still loads the full global skill library and supports add/install/detail/edit, but skill card switches and detail enable toggles patch only `enabledSkillNames` for the target DeepChat agent. - - `/plugins/mcp` renders the existing `McpSettings` view in `scope="agent"` mode. It still supports add server, marketplace, detail, tools/prompts/resources views, but server card switches patch only `enabledMcpServerIds` for the target DeepChat agent. - - Target agent resolution: active session `agentId` -> selected agent id -> `deepchat`. - - When an allow-list is absent and the user toggles one item, initialize from currently globally available items, then apply the requested toggle. - - Preserve hidden existing allow-list ids when saving to avoid dropping entries not currently rendered. -8. Add tests for config normalization, runtime MCP filtering, runtime skill filtering, plugin-owned MCP scoping, and policy persistence. - -## Affected Interfaces - -- `src/shared/types/agent-interface.d.ts` -- `src/main/presenter/agentRepository/index.ts` -- `src/main/presenter/agentRuntimePresenter/index.ts` -- `src/main/presenter/pluginPresenter/index.ts` -- `src/main/presenter/skillPresenter/index.ts` -- `src/main/presenter/mcpPresenter/index.ts` -- `src/main/presenter/mcpPresenter/toolManager.ts` -- `src/main/presenter/mcpPresenter/agentMcpFilter.ts` -- `src/shared/contracts/routes/config.routes.ts` if adding dedicated policy routes -- `src/renderer/api/ConfigClient.ts` -- `src/renderer/settings/components/skills/SkillsSettings.vue` -- `src/renderer/settings/components/McpSettings.vue` -- `src/renderer/src/components/mcp-config/components/McpServers.vue` -- `src/renderer/src/pages/plugins/SkillsPluginsPage.vue` -- `src/renderer/src/pages/plugins/McpPluginsPage.vue` - -## Compatibility and Migration - -- No database schema migration is required if policy is stored in existing `agents.config_json`. -- Existing sessions keep their stored `agentId`; runtime policy is resolved dynamically from that agent's current config. -- Existing global skill/plugin/MCP definitions remain untouched. -- Existing ACP shared MCP selections remain compatible, but must not be treated as DeepChat agent MCP policy. -- If policy fields are absent, built-in `deepchat` behaves as before. - -## Test Strategy - -- Unit tests for `AgentRepository.resolveDeepChatAgentConfig()` merging policy fields. -- Runtime tests or focused presenter tests to assert unavailable MCP servers/tools are not exposed and cannot be called for a DeepChat agent. -- Runtime tests or focused presenter tests to assert unavailable skills are not shown in prompt and cannot be loaded as active skills. -- Presenter tests for plugin-owned MCP scoped server naming/status and per-agent plugin runtime activation/deactivation. -- Renderer/component tests: - - `SkillsSettings` in agent scope renders original controls and saves `enabledSkillNames` without global `setSkillDisabled`. - - `McpServers`/`McpSettings` in agent scope renders original controls and saves `enabledMcpServerIds` without global `toggleServer`. -- Manual smoke: create two DeepChat agents, select different MCP/skills/plugins, switch sessions, inspect available skills/tool list and plugin-owned MCP status. - -## Validation Commands - -- `pnpm run format` -- `pnpm run i18n` -- `pnpm run lint` -- Targeted tests if added or existing relevant suites are identified. diff --git a/docs/architecture/agent-scoped-extensions/spec.md b/docs/architecture/agent-scoped-extensions/spec.md index 34d741a65..ba0e34472 100644 --- a/docs/architecture/agent-scoped-extensions/spec.md +++ b/docs/architecture/agent-scoped-extensions/spec.md @@ -1,55 +1,58 @@ -# Agent-scoped Plugins, Skills, and MCP +# Agent-scoped Skills and MCP ## User Need -当前插件、技能和 MCP 已经在导航/视图上被放进 Plugins / agent 相关入口,但底层数据仍主要是全局或 ACP/shared 维度:切换不同 DeepChat agent 后,看到和运行时使用的插件、技能、MCP server 没有按 DeepChat agent 隔离,只是 UI 视角做了拆分。 +Skills and normal MCP servers are global resources, but different DeepChat agents need independent +choices about which of those resources they can use. The Plugins pages must preserve the existing +management experience while interpreting enable/disable actions as edits to the current DeepChat +agent. -用户进一步明确:Chat Plugins 下的 Skills 与 MCP 页面不应改成新的简化选择器视图。页面应保持原有 Skills/MCP 管理体验(搜索、添加、安装、市场、详情等),但在该入口里点击启用/禁用时,必须修改当前 DeepChat agent 的配置,而不是全局禁用同名 skill 或全局关闭同一个 MCP server。 +Official plugins are different: their installation, enablement, process lifecycle, contributed +resources, and status are global. ACP agents use an external runtime and cannot use the Plugins Hub. ## Goal -让 DeepChat agent 能拥有独立的插件、技能与 MCP 配置,并确保 UI 展示、运行时可用能力、提示词/工具装配与持久化状态使用同一份 agent-scoped 数据。 - -Chat Plugins 入口的 Skills/MCP 子页需要复用原有管理视图,同时把“启用/禁用”解释为当前 agent 的 allow-list 编辑。 +Give each DeepChat agent an independent Skills and MCP policy while keeping resource installation +global. Keep plugin availability global for DeepChat agents and unavailable for ACP agents. ## Acceptance Criteria -1. 每个 DeepChat agent 可以独立配置可用技能集合。 -2. 每个 DeepChat agent 可以独立配置可用插件集合,并影响该 agent 的插件 runtime 生命周期。 -3. 每个 DeepChat agent 可以独立配置可用 MCP server 集合。 -4. 切换 agent 后,设置页展示该 agent 自己的插件/技能/MCP 启用状态,而不是只展示全局列表。 -5. 发起会话或继续会话时,运行时按会话绑定的 `agentId` 解析对应 agent 的插件/技能/MCP 配置。 -6. 保留现有全局插件安装、MCP server 定义、技能安装/导入目录、外部 agent 技能同步能力;agent-scoped 配置只决定 DeepChat agent 是否使用这些资源。 -7. 缺省兼容:已有用户升级后,内置 `deepchat` agent 继续拥有当前全局行为,其他 agent 继承内置 `deepchat` 的配置。 -8. 设置页避免把“外部工具 agent(Claude/Codex 等技能目录同步)”、ACP shared MCP selections 和“DeepChat agent 配置”混为同一个作用域。 -9. `/plugins/skills` 保持原有 Skills 管理页面形态,仍可添加/安装/查看/编辑 skill;该页切换 skill 时只写当前 DeepChat agent 的 `enabledSkillNames`,不得调用全局 `setSkillDisabled`。 -10. `/plugins/mcp` 保持原有 MCP 管理页面形态,仍可添加 server、进入市场、查看详情;该页切换 server 时只写当前 DeepChat agent 的 `enabledMcpServerIds`,不得调用全局 `toggleServer`。 -11. Skills/MCP agent-scoped 页面进入时按当前会话 `agentId` 优先,其次当前选中的 agent,最后 `deepchat` 查询 agent 配置。 -12. 运行时 skill discovery/view 工具必须受当前 DeepChat agent 的 `enabledSkillNames` 约束;agent 禁用的 skill 不应出现在 `skill_list`,也不能被 `skill_view` 读取。 +1. Each DeepChat agent can independently configure its available skill set. +2. Each DeepChat agent can independently configure its available normal MCP server set. +3. Switching DeepChat agents updates Skills and MCP enablement from that agent's policy. +4. Runtime prompt, skill tools, MCP definitions, and MCP calls use the session agent's policy. +5. Existing global skill installation/import and MCP server definitions remain global resources. +6. Existing users retain compatible behavior: the built-in `deepchat` agent defaults to globally + available Skills and MCP servers; other DeepChat agents inherit its policy. +7. `/plugins/skills` keeps the full Skills management view and writes `enabledSkillNames` instead of + globally disabling a skill. +8. `/plugins/mcp` keeps the full MCP management view and writes `enabledMcpServerIds` instead of + globally disabling a server when used in agent scope. +9. Skill discovery, `skill_list`, and `skill_view` enforce `enabledSkillNames`. +10. Plugin-owned MCP servers and skills follow global plugin enablement and are not separately + filtered by a DeepChat agent plugin policy. ## Constraints -- 不降低现有插件信任、安装、运行时权限、安全校验。 -- 不把插件安装状态复制到每个 agent;安装/更新/信任仍是全局资源生命周期,但运行时启用按 agent 拆分。 -- 不复制 MCP server 定义到每个 agent;MCP server 配置仍是全局资源,agent 只保存选择/启用策略。 -- 不复制技能文件到每个 agent;技能文件库仍是全局资源,agent 只保存选择/启用策略。 -- 不破坏现有会话级 active skills:用户消息或工具临时激活的 skill 仍可作为本轮上下文状态,但必须受 agent 可用集合约束。 -- 遵循现有 Presenter、typed route contracts、renderer API client 与 Vue 组件模式。 -- Skills/MCP 原有管理能力(添加、详情、市场、安装等)仍是全局资源管理;仅启用/禁用状态在 Chat Plugins agent-scoped 入口下改为 agent allow-list。 +- Do not copy skill files or MCP definitions per agent; store only allow-lists in agent config. +- Keep session-level active skills bounded by the agent's available skill set. +- Keep plugin installation, trust, enablement, lifecycle, and status global. +- Keep ACP shared MCP selections separate from DeepChat agent MCP policy. +- Preserve the Presenter, typed route, renderer client, and Vue ownership boundaries. ## Non-goals -- 不实现插件市场、多源插件安装或插件沙箱重构。 -- 不改变第三方外部工具的技能目录格式。 -- 不迁移或删除当前全局技能库、插件安装库。 -- 不把 ACP agent 的外部运行时插件化。 -- 不重做 Skills/MCP 页面布局或替换为简化策略面板。 +- Per-agent plugin process instances or plugin allow-lists. +- Changing external tool agents' skill directory formats. +- Removing the global Skills library or MCP server catalog. +- Adapting DeepChat plugins for ACP agents. +- Replacing Skills or MCP management pages with a generic policy panel. ## Decisions -- 插件 agent-scoped 行为需要覆盖插件 runtime 生命周期:插件进程也应按 DeepChat agent 启停,而不是只在运行时过滤贡献能力。 -- 插件 runtime 生命周期按 agent 配置启用/禁用管理,不按单会话引用计数管理。 -- 插件贡献的 MCP server 只跟随插件启用;某 agent 启用插件后,该插件 owned MCP 自动属于该 agent,不再要求额外进入 MCP 选择列表。 -- MCP 也需要纳入 DeepChat agent scope;现有 ACP/shared MCP selections 不能代表 DeepChat agent 的 MCP 配置。 -- 新建 DeepChat agent 默认继承内置 `deepchat` agent 的插件/技能/MCP 配置。 -- Chat Plugins 的 Skills/MCP 页面复用原有管理 UI;通过 scope prop 切换保存语义,避免用户失去添加 skill/server 等原有入口。 +- `DeepChatAgentConfig.enabledSkillNames` and `enabledMcpServerIds` are nullable allow-lists. +- `null` / `undefined` inherits the built-in `deepchat` policy; `[]` disables the category. +- Plugin-owned MCP servers follow the owning plugin and are excluded from normal MCP selection. +- Plugins are globally enabled for every DeepChat agent; `enabledPluginIds` is not part of agent + config. +- ACP selection replaces the Plugins Hub with an unavailable state. diff --git a/docs/architecture/agent-scoped-extensions/tasks.md b/docs/architecture/agent-scoped-extensions/tasks.md deleted file mode 100644 index 035df2fc9..000000000 --- a/docs/architecture/agent-scoped-extensions/tasks.md +++ /dev/null @@ -1,19 +0,0 @@ -# Tasks: Agent-scoped Plugins, Skills, and MCP - -- [x] Inspect current plugin, skill, MCP, and DeepChat agent architecture. -- [x] Create SDD artifacts for agent-scoped extension architecture. -- [x] Resolve open questions about plugin runtime boundary and new-agent defaults. -- [x] Extend DeepChat agent config types with plugin/skill/MCP policy fields. -- [x] Normalize/merge policy fields in `AgentRepository`. -- [x] Filter runtime MCP tools/servers by session agent policy and enforce call-time allow-list checks. -- [x] Filter runtime skill catalog and active skill loading by session agent policy. -- [ ] Split plugin-contributed runtime resources and plugin-owned MCP servers by session agent policy. -- [x] Add renderer client/helpers for reading and saving selected agent policies. -- [x] Add chat plugin hub UI for per-agent plugin/skill/MCP selection. -- [x] Keep `/plugins/skills` on the original Skills management view while making enable/disable update only the current agent. -- [x] Keep `/plugins/mcp` on the original MCP management view while making server enable/disable update only the current agent. -- [x] Update i18n strings. -- [x] Add or update tests. -- [x] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck:web`, and targeted Vitest suites after the latest UI-scope correction. -- [x] Address review findings: preserve undefined MCP/plugin runtime policies, respect the global MCP master switch in agent pages, use effective agent config in management views, keep skill detail state fresh, allow agent-scoped toggles for DeepChat-managed MCP servers, and enforce `enabledSkillNames` for `skill_list`/`skill_view`. -- [x] Address review hardening for plugin-sourced MCP ownership, stale agent policy loads, and omitted active-skill overrides. diff --git a/docs/architecture/plugin-availability-boundary/spec.md b/docs/architecture/plugin-availability-boundary/spec.md new file mode 100644 index 000000000..fabb781db --- /dev/null +++ b/docs/architecture/plugin-availability-boundary/spec.md @@ -0,0 +1,69 @@ +# Plugin Availability Boundary + +## User Need + +The Plugins Hub must not mix two incompatible availability models: + +- ACP agents run in an external runtime and cannot consume DeepChat plugins. +- DeepChat plugins are enabled globally; a per-agent `enabledPluginIds` allow-list would filter only + some plugin-owned MCP servers and skills without scoping plugin process lifecycle. + +The ACP case needs an explicit unavailable state. A partial per-agent plugin policy must remain +absent because it exposes a scope control that the runtime does not implement consistently. + +## Goal + +Define one plugin availability rule: + +- ACP agent selected: the Plugins Hub is unavailable. +- DeepChat agent selected, or no agent selected: every globally enabled plugin is available. +- Skills and normal MCP servers keep their existing DeepChat agent-scoped selection behavior. + +## Acceptance Criteria + +1. Selecting an ACP agent replaces the Plugins Hub tabs and child route with a clear unavailable + state. +2. The unavailable state explains that ACP agents manage extensions through their own runtime and + tells the user to select a DeepChat agent. +3. Switching back to a DeepChat agent restores the current Plugins route without another IPC read. +4. The plugin catalog no longer renders an agent-scope policy panel. +5. `DeepChatAgentConfig` no longer exposes or resolves `enabledPluginIds`. +6. Plugin-owned MCP servers and skills follow the owning plugin's global enabled state and are not + filtered by a per-agent plugin allow-list. +7. `enabledSkillNames` and `enabledMcpServerIds` retain their current per-agent semantics. +8. Existing persisted `enabledPluginIds` values no longer affect runtime behavior; no database + migration is required. + +## Constraints + +- Global plugin installation, trust, enablement, runtime lifecycle, and status stay owned by + `PluginPresenter`. +- Remote virtual plugins keep their existing global channel configuration and lifecycle. +- The renderer must derive ACP availability from the existing agent store; do not add IPC, a new + store, or duplicated agent state. +- Do not change ACP runtime configuration or attempt to inject DeepChat plugins into ACP agents. +- Preserve the existing agent-scoped Skills and MCP management views. + +## Non-goals + +- Per-agent plugin runtime instances. +- Plugin process reference counting by session. +- ACP plugin installation or adaptation. +- Redesigning the Skills or MCP pages. +- Migrating or deleting historical JSON keys from stored agent records. + +## Decisions + +- `PluginsHubPage` owns the ACP availability gate because it covers catalog, detail, Skills, and MCP + child routes in one place. +- The selected agent type is `agent.agentType ?? agent.type`; a missing selection is treated as the + normal DeepChat/global view. +- The ACP state replaces the whole Plugins Hub surface instead of disabling individual controls. +- Plugin availability is global for DeepChat. The global plugin enabled state is the only plugin + availability source of truth. +- Old `enabledPluginIds` data is ignored by removing it from config resolution and runtime access + contexts. Keeping a dormant compatibility field would preserve the ambiguity this change removes. + +## Open Questions + +None. diff --git a/docs/features/plugins-hub/plan.md b/docs/features/plugins-hub/plan.md deleted file mode 100644 index a88fc62d5..000000000 --- a/docs/features/plugins-hub/plan.md +++ /dev/null @@ -1,454 +0,0 @@ -# Plugins Hub Implementation Plan - -## Strategy - -Do not build a new plugin platform and do not add a new window. Move the UI ownership boundary into -the existing main window. - -The smallest reliable implementation is: - -- Add `/plugins` routes to the existing main renderer router. -- Keep `WindowSideBar` and `AppBar` as the app shell around the Plugins page. -- Reuse current clients/stores/components where they already own behavior. -- Treat Remote channels as renderer-only virtual plugin cards backed by existing `remoteControl.*` routes. -- Hide Settings navigation entries for Plugins-owned areas, while keeping compatibility redirects. -- Update the main sidebar expanded layout only; keep collapsed behavior unchanged. - -No data migration is required. - -## Main Route Architecture - -```text -Main window - App.vue - AppBar - WindowSideBar - RouterView - /chat -> ChatTabView - /welcome -> WelcomePage - /plugins -> PluginsHubPage - /plugins - /plugins/skills - /plugins/mcp - /plugins/:pluginId -``` - -Use the existing `src/renderer/src/router/index.ts`. Do not add `src/renderer/plugins`, a new Vite -entry, or a new BrowserWindow. - -Route names can be: - -```text -plugins -plugins-skills -plugins-mcp -plugins-detail -``` - -External/main-process callers should not know UI component internals. Reuse the existing app-runtime event path where possible. Only add a narrow route if a future main-process caller needs generic main-window navigation: - -```text -system.openMainRoute({ routeName: 'plugins-mcp', params? }) -> { focused: boolean } -``` - -For the first increment, MCP install deeplinks reuse `DEEPLINK_EVENTS.MCP_INSTALL` and the main app deeplink handler routes the renderer to `/plugins/mcp`. Do not add a Plugins-specific window route. - -## Affected Boundaries - -| Boundary | Required change | -| --- | --- | -| `src/renderer/src/router/index.ts` | Add `/plugins` route family | -| `src/renderer/src/App.vue` | Keep existing shell; ensure `/plugins` receives same global overlays/theme/i18n | -| `WindowSideBar.vue` | Add expanded command list and route Plugins row to `/plugins` | -| `renderer/api` | Reuse existing clients; add a main-window navigation client only if a generic main-process caller appears | -| `shared/contracts/routes` | Add narrow focus/navigate route only if deeplink/main process cannot use existing event path | -| Settings renderer | Remove/hide Plugins-owned nav entries and overview links | -| Deeplink presenter | Route MCP install deeplink to main `/plugins/mcp` page | -| Plugin presenter | Stop using per-plugin BrowserWindow as primary UI path | - -## Data Ownership - -Do not create a persisted unified plugin table. - -Use a renderer-only union for cards: - -```text -CatalogItem = - official plugin item from plugins.list - Remote virtual item from remoteControl.listChannels + status -``` - -`MCP` and `Skills` are top-level sibling tabs under `/plugins`, not catalog cards. This union only drives plugin catalog rendering and search filtering. Writes go back to the current owner: - -| User action | Owner route/client | -| --- | --- | -| Enable official plugin | `PluginClient.enablePlugin` | -| Disable official plugin | `PluginClient.disablePlugin` | -| CUA runtime/permission action | `PluginClient.invokeAction` existing runtime actions | -| MCP add/edit/toggle | `McpClient` / `useMcpStore` existing paths | -| Skill install/edit/delete/sync | `SkillClient` / `SkillSyncClient` / `useSkillsStore` | -| Remote enable/save/pair/remove binding | `RemoteControlClient` | - -## Page Shell - -Create the Plugins UI under the existing renderer: - -```text -src/renderer/src/pages/plugins/ -├── PluginsHubPage.vue -├── PluginsCatalogPage.vue -├── OfficialPluginDetailPage.vue -├── McpPluginsPage.vue -├── SkillsPluginsPage.vue -├── components/ -│ ├── PluginsTopTabs.vue -│ ├── PluginCatalogGrid.vue -│ ├── PluginCatalogCard.vue -│ ├── AddedPluginsStrip.vue -│ └── PluginSearchBar.vue -└── composables/ - ├── usePluginCatalog.ts - └── useRemotePluginItems.ts -``` - -Keep this list flexible during implementation; do not split files unless the component becomes hard to read. - -Visual baseline: - -- Main content starts with top tabs (`Plugins`, `Skills`, `MCP`). -- Catalog page uses the Codex-like layout: title, subtitle, search, added strip, segmented filters, sectioned list. -- Catalog cards include official plugins and Remote virtual plugins; MCP and Skills remain reachable through top tabs. -- Remote does not have a top tab or product list route. Each channel opens as a virtual plugin detail. -- Avoid settings-style full-width form pages for the catalog. Detail routes may use denser settings sections. -- Cards are individual repeated items only. Do not put page sections inside floating cards. - -## Route and Navigation Behavior - -Renderer-side navigation: - -| Trigger | Behavior | -| --- | --- | -| Sidebar `Plugins` row | `router.push({ name: 'plugins' })` | -| Top tab `Skills` | `router.push({ name: 'plugins-skills' })` | -| Top tab `MCP` | `router.push({ name: 'plugins-mcp' })` | -| Plugin card/detail | `router.push({ name: 'plugins-detail', params: { pluginId } })` | -| Remote channel card/detail | `router.push({ name: 'plugins-detail', params: { pluginId: 'remote:' } })` | -| `New Chat` row while on `/plugins` | `router.push({ name: 'chat' })`, then start new conversation | - -Main-process initiated navigation: - -- MCP install deeplink must focus the main window and route to `/plugins/mcp`. -- Historical Settings route redirects can focus main and route to the matching `/plugins...` route. -- If the main window does not exist, create/focus the normal app window, not a Plugins window. - -## Official Plugin Detail - -Current behavior opens `PluginPresenter.openPluginSettingsWindow(pluginId)`. - -Target behavior: - -- List page opens `/plugins/:pluginId`. -- Detail page loads `plugins.get(pluginId)`. -- Enable/disable remains in detail and list. -- Runtime status and MCP status remain visible. -- Known first-party plugin actions are exposed as native detail sections: - - `runtime.getStatus` - - `runtime.checkPermissions` - - `runtime.openPermissionGuide` - -Do not add a generic embedded HTML settings host in the first increment. Current shipped plugins are first-party (`cua`, `feishu`), so native Vue detail pages are enough and safer than enabling arbitrary webview/iframe behavior. - -Legacy fallback: - -- Keep `settingsContributions` in manifests during migration. -- Keep `settings.open` action available only as a temporary compatibility path if some old package still calls it. -- The first-party UI must not call `settings.open`. - -When to add a generic plugin settings host: - -- Only when third-party plugin settings contributions are a supported product requirement. -- Use an isolated child WebContents/WebContentsView with the plugin-specific preload, not an iframe without preload. -- Keep external navigation denied. - -## MCP Migration - -`McpSettings.vue` already owns most behavior. Move by reuse, not rewrite. - -Recommended first pass: - -- Create `McpPluginsPage.vue`. -- Import/reuse `McpServers`, `McpBuiltinMarket`, NPM registry controls, guide overlay only if still needed. -- Preserve current route query shape for market view inside Plugins (`/plugins/mcp?view=market`). -- Move deeplink handler from Settings bootstrap to main app or Plugins page bootstrap for MCP install. - -Compatibility: - -- `deepchat://mcp/install` focuses main window and routes to `/plugins/mcp`. -- Hidden `settings-mcp` route can redirect/open main `/plugins/mcp` during transition. - -Settings cleanup: - -- Remove visible `settings-mcp` navigation item. -- Remove MCP Overview metric. -- Remove `start-mcp` quick task or replace it with a non-Plugins Settings task. - -## Skills Migration - -`SkillsSettings.vue` can become a Plugins page with minimal changes: - -- Rename/wrap visually as `SkillsPluginsPage`. -- Keep `SkillCard`, `SkillInstallDialog`, `SkillEditorSheet`, `SkillSyncDialog`, `SyncStatusSection`. -- Keep draft suggestion toggle. -- Keep first-launch sync prompt if product still relies on it. - -Avoid duplicating the skills store or install logic. - -Compatibility: - -- Hidden `settings-skills` route should route the main window to `/plugins/skills`. -- Settings Overview search should not list Skills. - -## Remote Migration - -First increment: reuse `RemoteSettings.vue` inside `/plugins/:pluginId` for virtual plugin ids such as `remote:telegram`. The detail shell owns the plugin-style enable/disable button, while single-channel mode hides the old Remote tab strip and the embedded channel toggle. Feishu/Lark Integration uses the same hide-toggle mode so its top-level plugin enable button controls both the official plugin and Feishu/Lark Remote. This keeps the existing credential, pairing, default agent/workdir, bindings and WeChat iLink behavior intact. - -Follow-up refactor: extract channel sections from `RemoteSettings.vue` into reusable components. The file is already large, but splitting it before moving the route would increase regression risk and delay the user-visible entry-point cleanup. - -Refactor only around real channel boundaries: - -```text -PluginsCatalogPage - -> virtual cards from listRemoteChannels() -PluginDetailPage(remote:) - -> channel header/status/toggle - -> credentials section - -> default agent/workdir section - -> pairing section when supportsPairing - -> bindings section - -> channel-specific section -``` - -Suggested extracted components: - -| Component | Scope | -| --- | --- | -| `RemotePluginCard` | card summary for one channel | -| `PluginDetailPage(remote:)` | detail shell and save status | -| `RemoteCredentialsSection` | token/app secret fields; channel-specific props | -| `RemoteDefaultsSection` | default agent and default workdir | -| `RemotePairingSection` | pair code and principals for pairable channels | -| `RemoteBindingsSection` | bound chats/groups/topics | -| `WeixinIlinkAccountsSection` | WeChat iLink login/account controls | - -Keep shared logic tiny: - -- load channel settings -- save channel settings -- load channel status -- load bindings/pairing - -Do not invent a generic form schema for all channels. - -Virtual item mapping: - -```text -remote: - kind: remote - title: channel title + ' Remote' when needed - description: descriptor.descriptionKey - enabled: status.enabled - state: status.state - detailRoute: /plugins/:pluginId -``` - -## Settings Removal and Redirects - -Change visible navigation source: - -- Remove or mark hidden: - - `settings-mcp` - - `settings-remote` - - `settings-plugins` - - `settings-skills` - -Because `settingsNavigation.ts` is the single source for Settings sidebar and Overview search, this should remove most visible Settings entries without scattered conditions. - -Route compatibility options: - -1. Keep hidden route items so old route names still exist. -2. When entered, focus main window and navigate to the mapped `/plugins...` route. -3. Do not show these items in Settings sidebar/search. - -Mapping: - -| Old Settings route | Main window target | -| --- | --- | -| `settings-mcp` | `/plugins/mcp` | -| `settings-remote` | `/plugins` | -| `settings-plugins` | `/plugins` | -| `settings-skills` | `/plugins/skills` | - -`settings-acp` stays in Settings for this feature. ACP is an agent/provider configuration surface, not part of the four requested Plugins-owned areas. - -## Sidebar Implementation Plan - -Current `WindowSideBar.vue` should not be rewritten. Modify the expanded right column header area. - -Before: - -```text -right column -├── header row: selectedAgentName + group toggle + plus -├── search input -├── pinned section -└── session groups -``` - -After: - -```text -right column -├── title row: selectedAgentName -├── command list -│ ├── New Chat -│ ├── Search -│ └── Plugins -├── blank spacer -├── pinned section when non-empty -├── Chat group -├── 工作区 header + existing group-mode/sort toggle -└── project groups -``` - -Command behavior: - -| Row | Existing behavior to call | -| --- | --- | -| New Chat | `router.push({ name: 'chat' })` then `sessionStore.startNewConversation({ refresh: true })` | -| Search | `spotlightStore.toggleSpotlight()` | -| Plugins | `router.push({ name: 'plugins' })` | - -Keep: - -- Agent icon rail. -- Settings/theme/sidebar controls in the existing left rail. -- collapsed width and transitions. -- session pagination and fill checks. -- pinned collapse behavior. -- project grouping/reorder behavior. -- existing group-mode/sort behavior, moved to the `工作区` header. -- shortcut badge logic for sessions. - -Question the old inline session search: - -- First increment should remove the inline search input from expanded sidebar to match the requested command-list shape. -- Search row opens Spotlight, which already searches sessions/messages/settings/actions. -- If users later need local-only filtering, add it inside Spotlight or as a session-list filter command, not as a second persistent input. - -Right column ordering: - -```text -所有 Agents - -New Chat -Search -Plugins - -Pinned (only if any) -... -Chat -... -工作区 [group/sort toggle] -project groups -... -``` - -Do not add Settings, theme, collapse, remote status or other rail controls into this right column. - -## Deeplinks and External Entry Points - -Update callers: - -| Current caller | New behavior | -| --- | --- | -| MCP install deeplink | focus main window, route to `/plugins/mcp`, dispatch MCP install event there | -| Settings sidebar old MCP/Skills/Plugins/Remote | no visible entry | -| Settings activity old route | focus main window and route to matching `/plugins...` page | -| Sidebar remote status button | route to the first enabled `remote:` plugin detail | -| Chat input MCP indicator `openSettings` text | route to `/plugins/mcp` | - -Provider install deeplink stays in Settings Provider. Do not route provider/model setup to Plugins. - -## i18n - -Add route/page labels: - -- `routes.plugins` -- `pluginsHub.title` -- `pluginsHub.subtitle` -- `pluginsHub.searchPlaceholder` -- `pluginsHub.tabs.plugins` -- `pluginsHub.tabs.skills` -- `pluginsHub.tabs.mcp` -- `pluginsHub.tabs.remote` -- sidebar command labels if existing `common.newChat` and spotlight labels are not enough. - -Avoid moving existing `settings.mcp`, `settings.skills`, `settings.remote` keys in the first increment. Reuse them from Plugins pages to keep the diff smaller. Later cleanup can rename namespaces if the old naming becomes misleading. - -## Testing Strategy - -Small checks with high signal: - -| Area | Tests | -| --- | --- | -| Main router | `/plugins` and child routes render inside app shell | -| Settings navigation | removed entries do not appear in `getSettingsNavigationGroups`; hidden redirects still resolve | -| Sidebar | expanded command rows render; collapsed state unchanged; Plugins row routes to `/plugins` | -| Remote virtual items | descriptors + status produce cards; detail saves via `remoteControl.saveChannelSettings` | -| Official plugin detail | list/detail enable-disable; settings button no longer calls `settings.open` | -| Deeplink | MCP install focuses main and routes to `/plugins/mcp` | - -Manual visual QA: - -- macOS light/dark with app sidebar and main content. -- Windows light/dark with main app shell. -- Linux opaque backgrounds. -- Narrow width with collapsed and expanded sidebar. -- Long remote token/error/path strings. -- Chinese and English labels. - -Final implementation gates: - -```bash -pnpm run format -pnpm run i18n -pnpm run lint -pnpm run typecheck -``` - -Renderer tests should be run for touched components. Full app smoke test should open Chat, Plugins and Settings separately. - -## Risks and Mitigations - -| Risk | Mitigation | -| --- | --- | -| Settings routes are used by deeplinks/onboarding | Keep hidden compatibility routes and redirect to main `/plugins...` | -| RemoteSettings monolith makes migration risky | Reuse it in single-channel mode; extract per-channel sections only when needed | -| Plugin settings HTML depends on plugin preload | Do not embed arbitrary HTML in first increment; build first-party native details | -| Feishu official plugin vs Feishu Remote naming collision | Merge Feishu Remote into the Feishu/Lark Integration detail page | -| Plugins page becomes another Settings | Catalog page stays Codex-like; detail pages are dense only where settings are unavoidable | -| Main route conflicts with chat internal `pageRouter` | Use Vue router for `/plugins`; keep `pageRouter` scoped to ChatTabView | -| Search behavior confusion | Sidebar Search row opens existing Spotlight; do not add a new search engine | - -## Rollout Plan - -1. Land `/plugins` main route skeleton with top tabs and catalog placeholder. -2. Move visible Settings entries out, with redirects to main Plugins routes. -3. Move MCP page and deeplink. -4. Move Skills page. -5. Add official plugin native list/detail and stop first-party UI from opening plugin settings windows. -6. Add Remote virtual plugin list/detail. -7. Update main sidebar expanded command list. -8. Run visual QA and clean up i18n/tests. - -This order keeps each PR reviewable and avoids breaking every surface at once. diff --git a/docs/features/plugins-hub/spec.md b/docs/features/plugins-hub/spec.md index 64376e7a6..7795f2f61 100644 --- a/docs/features/plugins-hub/spec.md +++ b/docs/features/plugins-hub/spec.md @@ -23,6 +23,15 @@ servers、official Plugins 和 Remote control channels 时,不应该打开 Set Remote channel 是 Plugins UI 里的 virtual plugin,不是 `.dcplugin` 安装包。这个建模只改变用户入口和 展示方式,不改变 remote control 的配置存储、runtime 生命周期或消息协议。 +Availability boundary: + +- The Plugins Hub belongs to the DeepChat runtime. +- Globally enabled plugins are available to every DeepChat agent; there is no per-agent plugin + allow-list. +- ACP agents use their own external runtime. Selecting an ACP agent replaces the whole Plugins Hub + with an unavailable state instead of exposing controls that cannot affect that runtime. +- Skills and normal MCP servers may retain their separate DeepChat agent-scoped policies. + ## Goals - 新增主窗口 route:`/plugins`。 @@ -34,6 +43,8 @@ Remote channel 是 Plugins UI 里的 virtual plugin,不是 `.dcplugin` 安装 - Remote 每个 implemented channel 都作为 plugin-like card 出现,并能进入该 channel 的详情子路由。 - Remote 设置页不再在 Settings 中展示;从列表进入详情时使用主窗口 Plugins 子路由。 - Official plugin 的详情和设置入口不再弹出 per-plugin BrowserWindow;从列表进入详情时使用主窗口 Plugins 子路由。 +- Selecting an ACP agent shows one Plugins-unavailable state and does not render catalog, detail, + Skills, or MCP child routes. - Settings 侧边栏、Settings Overview 搜索和 quick entry 不再展示 Skills、MCP、Plugins、Remote。 - 保留 Settings 内部旧 route 的兼容能力,避免 deeplink、onboarding 或历史入口直接 404。 - `所有 Agents` 标题保留。 @@ -51,8 +62,9 @@ Remote channel 是 Plugins UI 里的 virtual plugin,不是 `.dcplugin` 安装 - 不新增统一持久化表来存一个“大插件模型”。 - 不改变 existing Remote commands、pairing protocol、channel binding behavior。 - 不改变 existing MCP server config schema、Skill sidecar schema 或 plugin manifest schema,除非内嵌设置页确实需要最小 route 补充。 +- 不把 DeepChat Plugins Hub 或其插件注入 ACP runtime。 -## Current State +## Current Architecture Relevant current files: @@ -63,23 +75,24 @@ Relevant current files: | Chat page internal route state | `src/renderer/src/stores/ui/pageRouter.ts`, `src/renderer/src/views/ChatTabView.vue` | | Settings shell and navigation | `src/renderer/settings/App.vue`, `src/renderer/settings/main.ts`, `src/shared/settingsNavigation.ts` | | Settings window lifecycle | `src/main/presenter/windowPresenter/index.ts`, `src/shared/contracts/routes/system.routes.ts` | -| Plugins settings page | `src/renderer/settings/components/PluginsSettings.vue`, `src/renderer/api/PluginClient.ts`, `src/shared/contracts/routes/plugins.routes.ts` | +| Plugins Hub | `src/renderer/src/pages/plugins/**`, `src/renderer/src/stores/pluginCatalog.ts`, `src/renderer/api/PluginClient.ts` | | MCP settings page | `src/renderer/settings/components/McpSettings.vue`, `src/renderer/src/components/mcp-config/**`, `src/renderer/src/stores/mcp.ts` | | Skills settings page | `src/renderer/settings/components/skills/SkillsSettings.vue`, `src/renderer/src/stores/skillsStore.ts` | | Remote settings page | `src/renderer/settings/components/RemoteSettings.vue`, `src/renderer/api/RemoteControlClient.ts` | -Important current constraints: +Important current boundaries: -- Main window Vue router currently exposes `/chat` and `/welcome`. -- Main shell already keeps `WindowSideBar` outside `RouterView`, so adding `/plugins` naturally preserves the sidebar. +- Main window Vue router exposes `/chat`, `/welcome`, and the nested `/plugins` route family. +- Main shell keeps `WindowSideBar` outside `RouterView`, so Plugins routes preserve the sidebar. - Settings navigation is centralized in `src/shared/settingsNavigation.ts`. - Settings routes are generated from navigation items in `src/renderer/settings/main.ts`. - `system.openSettings` only accepts `SettingsRouteNameSchema`. -- MCP install deeplinks currently open Settings and send `DEEPLINK_EVENTS.MCP_INSTALL`. -- Plugin settings currently call `plugins.invokeAction({ actionId: 'settings.open' })`, which opens a per-plugin BrowserWindow. -- Remote channels already expose `RemoteChannelDescriptor`, status, settings, bindings and pairing through typed routes. +- MCP install deeplinks focus the main window and route to `/plugins/mcp`. +- First-party plugin catalog and detail flows stay inside the main Plugins route family. +- Remote channels expose their main-owned descriptor catalog, status, settings, bindings and pairing + through typed routes; renderer state is a cache, not a second static catalog. -## Proposed Main Route Structure +## Main Route Structure ```text src/renderer/src/router/index.ts @@ -92,7 +105,7 @@ src/renderer/src/router/index.ts └── /plugins/:pluginId ``` -Implementation can use nested Vue routes or one `/plugins` route with internal tab state. The URL must be shareable enough for internal navigation and redirects: +The router uses nested Vue routes so each destination remains addressable for internal navigation and redirects: | Target | Required addressable route | | --- | --- | @@ -103,7 +116,7 @@ Implementation can use nested Vue routes or one `/plugins` route with internal t Legacy `/plugins/official/:pluginId`, `/plugins/remote` and `/plugins/remote/:channel` paths may redirect for compatibility, but they are not product routes. -## Proposed Information Architecture +## Information Architecture Top-level sections: @@ -115,6 +128,18 @@ Top-level sections: The visual top tab row uses `Plugins`, `Skills` and `MCP`. `Remote` is not a top tab; each remote channel is a virtual plugin card in the catalog. +When an ACP agent is selected, the tab row and child route are replaced together: + +```text +┌────────────────────────────────────────────┐ +│ │ +│ Plugins are unavailable │ +│ ACP agents use their own runtime. │ +│ Select a DeepChat agent. │ +│ │ +└────────────────────────────────────────────┘ +``` + Remote virtual plugin ids use `remote:`. Feishu/Lark is special: when the official Feishu/Lark Integration plugin is installed, the Feishu/Lark Remote card is merged into that official plugin detail page. Historical remote settings compatibility: if a channel has credentials/accounts from an older configuration and no explicit enabled flag, that virtual plugin starts enabled by default. Explicit `enabled: false` still stays disabled. @@ -194,7 +219,7 @@ Narrow behavior: ## Sidebar UX -### Target Expanded Shape +### Expanded Shape ```text ┌────┬──────────────────────────────┐ @@ -292,6 +317,9 @@ Compatibility behavior: - `AppBar`, sidebar, theme, language direction and global overlays continue to work. - The page has stable responsive behavior and remains usable at narrow widths. - User-facing strings use i18n keys. +- Selecting an ACP agent hides the tab row and child route behind one accessible unavailable state. +- Selecting a DeepChat agent restores the current Plugins route without a redirect or extra IPC + request. ### Official Plugins diff --git a/docs/features/plugins-hub/tasks.md b/docs/features/plugins-hub/tasks.md deleted file mode 100644 index a2133d0fd..000000000 --- a/docs/features/plugins-hub/tasks.md +++ /dev/null @@ -1,130 +0,0 @@ -# Plugins Hub Tasks - -## 0. Review Gate - -- [x] Review `spec.md` with product/maintainers. -- [x] Review `plan.md` main-route architecture, route compatibility, and sidebar layout. -- [x] Confirm no unresolved clarification markers exist before implementation. -- [ ] Keep this SDD folder active until the feature lands or is deliberately abandoned. - -## 1. Main Route Skeleton - -- [x] Add `/plugins` route family to the existing main renderer router. -- [x] Add `PluginsHubPage.vue` inside `src/renderer/src/pages/plugins/`. -- [x] Add top tab navigation for Plugins, Skills and MCP. -- [x] Add Codex-like catalog placeholder with title, subtitle, search, added strip and featured sections. -- [x] Keep MCP and Skills as top tabs only, not plugin catalog cards. -- [x] Keep `WindowSideBar`, `AppBar`, global overlays, theme and i18n behavior intact. -- [x] Add i18n keys for route, page title, subtitle, tabs and search placeholder. -- [ ] Add renderer tests proving `/plugins` renders inside the existing app shell. - -## 2. Main-Process Navigation Compatibility - -- [x] Reuse existing deeplink event handling for main-process initiated MCP navigation. -- [x] Ensure MCP install deeplink can focus/create the normal main window and navigate to `/plugins/mcp`. -- [x] Do not add a Plugins BrowserWindow. -- [x] Do not add `src/renderer/plugins` or a separate renderer entry. -- [ ] Add tests for focusing main and navigating to `/plugins/mcp`. - -## 3. Settings Navigation Cleanup - -- [x] Hide or remove visible Settings navigation items for MCP, Remote, Plugins, and Skills. -- [x] Keep compatibility routes or redirect handlers for old route names. -- [ ] Map every old route name to main `/plugins...` routes. -- [x] Remove MCP from Settings Overview primary metric. -- [x] Remove or replace Settings Overview `start-mcp` quick task. -- [x] Ensure Settings Overview search does not return hidden Plugins-owned pages. -- [ ] Update Settings activity click behavior for historical routes. -- [ ] Add tests for Settings navigation groups and hidden route handling. - -## 4. MCP Section - -- [x] Create `/plugins/mcp` page using current MCP store/client behavior. -- [x] Reuse `McpSettings`/current MCP components for list/add/edit/toggle. -- [x] Reuse MCP market view inside `/plugins/mcp?view=market`. -- [x] Reuse NPM registry controls. -- [x] Move MCP install deeplink target from Settings to main `/plugins/mcp`. -- [x] Move MCP install event handling into the main app or Plugins route bootstrap. -- [x] Keep plugin-owned MCP server read-only behavior. -- [ ] Add tests for deeplink route target and MCP page render. - -## 5. Skills Section - -- [x] Create `/plugins/skills` page from current Skills settings behavior. -- [x] Reuse skill list, search, install, edit, delete, sync import/export. -- [x] Preserve draft suggestion toggle. -- [x] Preserve first-launch sync prompt if still required. -- [x] Ensure skill dialogs/sheets fit the main Plugins page shell. -- [ ] Add renderer tests for empty/list/search/install entry behavior. - -## 6. Official Plugins Section - -- [x] Create official plugin list route from `PluginClient.listPlugins`. -- [x] Add unified detail route `/plugins/:pluginId`. -- [x] Keep enable/disable actions. -- [x] Show runtime status, plugin-owned MCP status and last errors. -- [ ] Add native CUA detail sections for runtime status, permissions and permission guide actions. -- [x] Merge Feishu/Lark Remote configuration into the Feishu/Lark Integration detail page. -- [x] Use the Feishu/Lark Integration top-level enable/disable button to control both the official plugin and Feishu/Lark Remote. -- [x] Stop first-party Plugins UI from calling `settings.open`. -- [x] Keep `settings.open` only as temporary compatibility fallback. -- [ ] Add tests for list/detail action behavior. - -## 7. Remote Virtual Plugins - -- [x] Build remote virtual cards from `remoteControl.listChannels`. -- [x] Fetch and display per-channel status. -- [x] Route remote virtual plugin cards through `/plugins/:pluginId` using `remote:` ids. -- [x] Remove the Remote top tab/product list route. -- [x] Reuse `RemoteSettings` in single-channel mode inside plugin detail pages. -- [x] Use the plugin detail top-level enable/disable button for remote virtual plugin state. -- [x] Auto-enable configured legacy channels when the explicit enabled flag is missing. -- [x] Preserve credentials fields and password reveal behavior. -- [x] Preserve enable/disable save behavior. -- [x] Preserve default agent and default workdir behavior. -- [x] Preserve pairing flow for Telegram, Feishu/Lark, QQBot and Discord. -- [x] Preserve binding/principal removal behavior. -- [x] Preserve WeChat iLink login/account controls. -- [x] Route sidebar remote status button to the first enabled remote plugin detail. -- [ ] Add tests for card mapping, save, pairing and bindings. - -## 8. Main Sidebar Layout - -- [x] Replace expanded sidebar header/search area with command list. -- [x] Keep `所有 Agents` title. -- [x] Wire `New Chat` row to navigate to `/chat` and start a new conversation. -- [x] Wire `Search` row to existing Spotlight behavior. -- [x] Localize the `Search` command label for Chinese locales. -- [x] Wire `Plugins` row to `router.push({ name: 'plugins' })`. -- [x] Add a blank spacer after the `Plugins` command row. -- [x] Render `Pinned` only when pinned sessions exist. -- [x] Keep the `Chat` group after `Pinned`. -- [x] Add `工作区` header before project groups. -- [x] Move the existing group-mode/sort toggle to the `工作区` header. -- [x] Keep Settings/theme/sidebar controls in the existing left rail, not in the expanded right column. -- [x] Display shortcut badges only for existing shortcuts. -- [x] Keep collapsed sidebar visual behavior unchanged. -- [x] Preserve session list pagination, pinned section, project grouping and reorder. -- [ ] Add renderer tests for expanded rows and collapsed state. -- [ ] Capture before/after ASCII blocks in PR description. - -## 9. Cross-Platform UI QA - -- [ ] Verify macOS light/dark with app shell and sidebar. -- [ ] Verify Windows light/dark with app shell and sidebar. -- [ ] Verify Linux opaque background. -- [ ] Verify narrow main window layout with expanded sidebar. -- [ ] Verify narrow main window layout with collapsed sidebar. -- [ ] Verify long paths/tokens/errors do not overflow. -- [ ] Verify keyboard navigation and focus order. -- [ ] Verify Chinese and English labels. - -## 10. Final Quality Gates - -- [x] Run `pnpm run format`. -- [x] Run `pnpm run i18n`. -- [x] Run `pnpm run lint`. -- [x] Run `pnpm run typecheck`. -- [ ] Run targeted renderer tests for Plugins route and sidebar. -- [ ] Run targeted main tests for navigation/deeplink behavior. -- [ ] Update durable docs or remove/archive active plan/tasks after implementation lands. diff --git a/docs/issues/runtime-history-global-trace-aggregation/spec.md b/docs/issues/runtime-history-global-trace-aggregation/spec.md new file mode 100644 index 000000000..471f959eb --- /dev/null +++ b/docs/issues/runtime-history-global-trace-aggregation/spec.md @@ -0,0 +1,166 @@ +# P-02 Runtime History Global Trace Aggregation + +## Status + +- Severity: High +- State: Implemented and validated +- Scope: `agent.db` message-history reads on the agent runtime path +- GitHub issue: [#1945](https://github.com/ThinkInAIXYZ/deepchat/issues/1945) + +## Issue + +Before this fix, `DeepChatMessagesTable.getBySession()` built a rich history projection by joining +a subquery that aggregated every row in `deepchat_message_traces` before SQLite filtered messages +to the requested session: + +```sql +LEFT JOIN ( + SELECT message_id, COUNT(*) AS trace_count + FROM deepchat_message_traces + GROUP BY message_id +) t ON t.message_id = m.id +``` + +With the current in-memory SQLite schema and indexes, the previous query plan was: + +```text +MATERIALIZE t +SCAN deepchat_message_traces USING COVERING INDEX idx_trace_message_seq +SEARCH m USING INDEX idx_deepchat_messages_session (session_id=?) +SEARCH t USING AUTOMATIC COVERING INDEX (message_id=?) LEFT-JOIN +``` + +Reading any session therefore scanned and aggregated the global trace table. The work grew with +all stored traces rather than the requested session history. P-01 could amplify this cost by +invoking the same rich history read more than once during a single send. + +## Impact + +- Runtime predicates, context assembly, compaction, and Tape maintenance pay for UI/debug metadata + they do not consume. +- Main-process synchronous SQLite work grows with global trace retention and can delay unrelated + windows and sessions. +- A small or fixed-size session becomes slower as traces accumulate in other sessions. +- Repeated runtime history reads multiply the same global aggregation cost. + +## Root Cause + +The base runtime history and the UI/debug history projection shared `getBySession()` and +`DeepChatMessageStore.getMessages()`. `traceCount` belongs to the UI/debug projection, but the +shared rich read made it an implicit dependency of runtime history. + +The available trace indexes do not make the current global aggregation session-bounded: + +- `idx_trace_message_seq (message_id, request_seq DESC)` +- `idx_trace_session_time (session_id, created_at DESC)` + +`listPageBySession()` already demonstrates the intended UI behavior: it computes trace counts per +selected message with an indexable correlated count. + +## Fix Plan + +1. Make `DeepChatMessagesTable.getBySession()` and `get()` the base full-session and single-message + reads, with no access to `deepchat_message_traces`. +2. Keep trace counts in explicit UI/debug projections only: + - `listPageBySession()` continues to use the correlated `message_id` count. + - Trace diagnostics continue to use `listByMessageId()` and `countByMessageId()`. +3. Treat `DeepChatMessageStore.getMessages()` and `getMessage()` as trace-free runtime reads. + Runtime records expose the existing neutral `traceCount` default, but runtime behavior must not + depend on it. +4. Audit and keep runtime predicates, context/resume assembly, compaction, truncation/rewind, and + Tape backfill/fact maintenance on the trace-free history path. +5. Keep renderer history restore and pagination on `listMessagesPage()` so trace dialog metadata + remains available without restoring a global rich history model. +6. Do not add a new index, rich single-message method, or boolean `includeTraceCount` switch unless + measurements show the existing paged projection and trace diagnostics are insufficient. + +## Compatibility and Constraints + +- No schema migration is required. +- No renderer, preload, IPC, or persisted message contract change is required. +- Paged UI/debug history must continue to report the exact trace count for each returned message. +- Context selection, pending-interaction guards, compaction, export, search, Tape backfill, delete, + truncate, rewind, and fork behavior must remain unchanged apart from no longer loading trace + counts on full-session reads. +- This issue does not remove trace capture, trace retention, or the trace dialog. +- This issue does not solve P-01's duplicate history reads; it removes the global trace-table cost + from each runtime read so P-01 can be addressed independently. + +## Acceptance Criteria + +- `getBySession(sessionId)` does not reference `deepchat_message_traces` and uses + `idx_deepchat_messages_session` for the requested session. +- Runtime predicate, context/resume, compaction, Tape, and operational single-message paths do not + execute trace-count queries. +- `listPageBySession()` still returns correct `trace_count` values, and explicit trace diagnostics + still return the requested message's trace rows and count. +- For a target session fixed at 1,000 messages, `getBySession()` work and latency remain effectively + flat when unrelated global traces increase from 0 to 10,000 to 100,000 rows. +- The query plan for `getBySession()` contains neither `MATERIALIZE` nor a scan/search of + `deepchat_message_traces`. +- Existing message ordering and structured-content materialization remain unchanged. + +## Validation + +### Correctness + +- Add a native SQLite table test proving `getBySession()` returns only the requested session in + ascending `order_seq` and `get()` returns one base row without requiring the trace table. +- Add or update message-store tests proving full-session and single-message runtime records + materialize content correctly without a loaded trace count. +- Retain coverage that the paged projection returns exact per-message counts. +- Exercise representative context, pending-interaction, compaction, Tape, truncate/rewind, export, + and search callers to catch accidental reliance on `traceCount`. + +### Query Plan + +Run `EXPLAIN QUERY PLAN` for the final full-session query and assert: + +```text +SEARCH deepchat_messages USING INDEX idx_deepchat_messages_session (session_id=?) +``` + +Reject plans that mention `deepchat_message_traces` or `MATERIALIZE`. + +### Scale Benchmark + +Use an in-memory native SQLite database with the production schema: + +| Target messages | Unrelated global traces | Warmed median | Ratio to 0 traces | +| ---: | ---: | ---: | ---: | +| 1,000 | 0 | 0.287 ms | 1.000x | +| 1,000 | 10,000 | 0.300 ms | 1.045x | +| 1,000 | 100,000 | 0.275 ms | 0.959x | + +Insert global traces outside the target session, warm the prepared query, and compare multiple +iterations by median rather than a single wall-clock sample. Use the query-plan assertion as the +deterministic regression gate; report the three timings and use their ratio as supporting evidence +instead of an environment-specific absolute millisecond threshold. The recorded values above are +from an Electron ABI 143 native SQLite run and serve as supporting evidence; the query-plan check +is the deterministic gate. + +### Results + +- Node typecheck passed. +- Focused message-store/runtime tests passed: 214 tests, with 4 native-ABI skips covered by the + Electron-native run. +- Context, compaction, and Tape tests passed: 108 tests, with 10 unrelated native-feature skips. +- Electron-native correctness and query-plan tests passed: 5 tests. +- Electron-native scale benchmark passed: 1 test. +- The repository-wide main suite completed with 3,359 passing tests and 6 failures in untouched + debug-fixture and agent-session integration tests. +- Formatting, i18n validation, and lint passed. + +## Tasks + +- [x] Remove trace aggregation from `DeepChatMessagesTable.getBySession()` and `get()`. +- [x] Audit every full-history and operational single-message caller. +- [x] Keep UI/debug trace counts on paged projection and explicit trace diagnostics. +- [x] Add correctness and query-plan regression tests. +- [x] Add the 0/10k/100k unrelated-trace scale benchmark for a fixed 1k-message session. +- [x] Run focused main-process tests and the performance test. +- [x] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. + +## Open Questions + +None. diff --git a/src/main/presenter/agentRepository/index.ts b/src/main/presenter/agentRepository/index.ts index 6e8bd4d3a..e6d7baf71 100644 --- a/src/main/presenter/agentRepository/index.ts +++ b/src/main/presenter/agentRepository/index.ts @@ -96,10 +96,6 @@ const mergeDeepChatConfig = ( systemPrompt: overrideConfig.systemPrompt ?? baseConfig.systemPrompt ?? '', permissionMode: overrideConfig.permissionMode ?? baseConfig.permissionMode ?? 'full_access', disabledAgentTools: overrideConfig.disabledAgentTools ?? baseConfig.disabledAgentTools ?? [], - enabledPluginIds: mergeNullableStringList( - baseConfig.enabledPluginIds, - overrideConfig.enabledPluginIds - ), enabledSkillNames: mergeNullableStringList( baseConfig.enabledSkillNames, overrideConfig.enabledSkillNames diff --git a/src/main/presenter/agentRuntimePresenter/dispatch.ts b/src/main/presenter/agentRuntimePresenter/dispatch.ts index 18d5cdd22..1ba591de9 100644 --- a/src/main/presenter/agentRuntimePresenter/dispatch.ts +++ b/src/main/presenter/agentRuntimePresenter/dispatch.ts @@ -1202,8 +1202,7 @@ async function runToolCall(params: { signal: io.abortSignal, permissionMode: toolPermissionMode, activeSkillNames: hooks?.getActiveSkillNames?.(), - enabledSkillNames: hooks?.getEnabledSkillNames?.(), - enabledPluginIds: hooks?.getEnabledPluginIds?.() + enabledSkillNames: hooks?.getEnabledSkillNames?.() }) let toolCallResult = await callTool() diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index f1ef8a173..fbcd5d015 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -220,7 +220,6 @@ type ResumeBudgetToolCall = { } type AgentExtensionPolicy = { - enabledPluginIds?: string[] | null enabledSkillNames?: string[] | null } @@ -4121,8 +4120,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { getActiveSkillNames: () => getEffectiveRuntimeSkillNames(), getEnabledSkillNames: () => this.normalizeNullablePolicyList(streamExtensionPolicy.enabledSkillNames), - getEnabledPluginIds: () => - this.normalizeNullablePolicyList(streamExtensionPolicy.enabledPluginIds), activateSkill: async (skillName) => { const policy = await this.resolveAgentExtensionPolicy(sessionId) if (this.filterSkillNamesByPolicy([skillName], policy).length === 0) { @@ -4925,10 +4922,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { extensionPolicy.enabledSkillNames === null || extensionPolicy.enabledSkillNames === undefined ? null : new Set(this.normalizeSkillNames(extensionPolicy.enabledSkillNames)) - const allowedPluginIdSet = - extensionPolicy.enabledPluginIds === null || extensionPolicy.enabledPluginIds === undefined - ? null - : new Set(this.normalizeSkillNames(extensionPolicy.enabledPluginIds)) if (skillsEnabled && skillPresenter) { if (skillPresenter.getMetadataList) { @@ -4937,12 +4930,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { const metadataList = await skillPresenter.getMetadataList() for (const metadata of metadataList) { const skillName = metadata?.name?.trim() - const ownerPluginId = metadata?.ownerPluginId?.trim() - if ( - skillName && - (!allowedSkillNameSet || allowedSkillNameSet.has(skillName)) && - (!ownerPluginId || !allowedPluginIdSet || allowedPluginIdSet.has(ownerPluginId)) - ) { + if (skillName && (!allowedSkillNameSet || allowedSkillNameSet.has(skillName))) { availableSkills.push({ name: skillName, description: metadata.description?.trim() || '', @@ -6783,7 +6771,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { const result = await this.toolPresenter.callTool(request, { agentId: this.getSessionAgentId(sessionId) ?? 'deepchat', enabledSkillNames: extensionPolicy.enabledSkillNames ?? undefined, - enabledPluginIds: extensionPolicy.enabledPluginIds ?? undefined, onProgress: (update) => { if ( update.kind !== 'subagent_orchestrator' || @@ -6936,7 +6923,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { const tools = await this.toolPresenter.getAllToolDefinitions({ agentId, - enabledPluginIds: policy.enabledPluginIds ?? undefined, disabledAgentTools: this.getDisabledAgentTools(sessionId), chatMode: 'agent', conversationId: sessionId, @@ -6987,7 +6973,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { disabledAgentTools: [...disabledAgentTools].sort((left, right) => left.localeCompare(right) ), - enabledPluginIds: this.normalizeNullablePolicyList(policy.enabledPluginIds), enabledSkillNames: this.normalizeNullablePolicyList(policy.enabledSkillNames), skillsEnabled, activeSkillNames @@ -7024,7 +7009,6 @@ export class AgentRuntimePresenter implements IAgentImplementation { try { const config = await this.configPresenter.resolveDeepChatAgentConfig(agentId) return { - enabledPluginIds: config.enabledPluginIds, enabledSkillNames: config.enabledSkillNames } } catch (error) { diff --git a/src/main/presenter/agentRuntimePresenter/types.ts b/src/main/presenter/agentRuntimePresenter/types.ts index 2bd6bcd9b..4863cc950 100644 --- a/src/main/presenter/agentRuntimePresenter/types.ts +++ b/src/main/presenter/agentRuntimePresenter/types.ts @@ -110,7 +110,6 @@ export interface ProcessHooks { ) => void getActiveSkillNames?: () => string[] getEnabledSkillNames?: () => string[] | null | undefined - getEnabledPluginIds?: () => string[] | null | undefined activateSkill?: (skillName: string) => Promise normalizeToolResult?: (tool: { sessionId: string diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 1b7a05577..0fee2eb11 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -31,7 +31,6 @@ import { extractToolCallImagePreviews } from '@/lib/toolCallImagePreviews' type McpToolAccessContext = { enabledTools?: string[] enabledServerIds?: string[] - enabledPluginIds?: string[] agentId?: string conversationId?: string } @@ -55,7 +54,6 @@ const normalizeToolAccessContext = ( return { enabledTools: normalizeStringList(input?.enabledTools), enabledServerIds: normalizeStringList(input?.enabledServerIds), - enabledPluginIds: normalizeStringList(input?.enabledPluginIds), agentId: input?.agentId?.trim() || undefined, conversationId: input?.conversationId?.trim() || undefined } @@ -158,11 +156,8 @@ export class McpPresenter implements IMCPPresenter { serverConfig: MCPServerConfig | undefined, context: McpToolAccessContext ): boolean { - const ownerPluginId = - serverConfig?.ownerPluginId?.trim() || - (serverConfig?.source === 'plugin' ? serverConfig.sourceId?.trim() : undefined) - if (ownerPluginId) { - return !context.enabledPluginIds || context.enabledPluginIds.includes(ownerPluginId) + if (this.isPluginOwnedServerConfig(serverConfig)) { + return true } return !context.enabledServerIds || context.enabledServerIds.includes(serverName) @@ -772,7 +767,7 @@ export class McpPresenter implements IMCPPresenter { async callTool( request: MCPToolCall, - options?: { agentId?: string; enabledServerIds?: string[]; enabledPluginIds?: string[] } + options?: { agentId?: string; enabledServerIds?: string[] } ): Promise<{ content: string; rawData: MCPToolResponse }> { const toolCallResult = await this.toolManager.callTool(request, options) const imagePreviews = await extractToolCallImagePreviews({ @@ -837,7 +832,7 @@ export class McpPresenter implements IMCPPresenter { */ async preCheckToolPermission( request: MCPToolCall, - options?: { agentId?: string; enabledServerIds?: string[]; enabledPluginIds?: string[] } + options?: { agentId?: string; enabledServerIds?: string[] } ): Promise<{ needsPermission: true toolName: string diff --git a/src/main/presenter/mcpPresenter/toolManager.ts b/src/main/presenter/mcpPresenter/toolManager.ts index fbe3a4463..f6e25e494 100644 --- a/src/main/presenter/mcpPresenter/toolManager.ts +++ b/src/main/presenter/mcpPresenter/toolManager.ts @@ -24,7 +24,6 @@ const CUA_PLUGIN_ID = 'com.deepchat.plugins.cua' type McpToolAccessContext = { enabledTools?: string[] enabledServerIds?: string[] - enabledPluginIds?: string[] agentId?: string conversationId?: string } @@ -45,7 +44,6 @@ const normalizeToolAccessContext = ( return { enabledTools: normalizeStringList(input?.enabledTools), enabledServerIds: normalizeStringList(input?.enabledServerIds), - enabledPluginIds: normalizeStringList(input?.enabledPluginIds), agentId: input?.agentId?.trim() || undefined, conversationId: input?.conversationId?.trim() || undefined } @@ -274,7 +272,7 @@ export class ToolManager { toolDefinitions: MCPToolDefinition[], context: McpToolAccessContext ): MCPToolDefinition[] { - if (!context.enabledTools && !context.enabledServerIds && !context.enabledPluginIds) { + if (!context.enabledTools && !context.enabledServerIds) { return toolDefinitions } @@ -306,11 +304,8 @@ export class ToolManager { serverConfig: MCPServerConfig, context: McpToolAccessContext ): boolean { - const ownerPluginId = - serverConfig.ownerPluginId?.trim() || - (serverConfig.source === 'plugin' ? serverConfig.sourceId?.trim() : undefined) - if (ownerPluginId) { - return !context.enabledPluginIds || context.enabledPluginIds.includes(ownerPluginId) + if (serverConfig.ownerPluginId?.trim() || serverConfig.source === 'plugin') { + return true } return !context.enabledServerIds || context.enabledServerIds.includes(serverName) } @@ -465,7 +460,7 @@ export class ToolManager { */ async preCheckToolPermission( toolCall: MCPToolCall, - access?: Pick + access?: Pick ): Promise<{ needsPermission: true toolName: string @@ -508,7 +503,6 @@ export class ToolManager { const accessContext = normalizeToolAccessContext({ agentId: access?.agentId, enabledServerIds: access?.enabledServerIds, - enabledPluginIds: access?.enabledPluginIds, conversationId: toolCall.conversationId }) if ( @@ -548,7 +542,7 @@ export class ToolManager { async callTool( toolCall: MCPToolCall, - access?: Pick + access?: Pick ): Promise { try { const finalName = toolCall.function.name @@ -589,7 +583,6 @@ export class ToolManager { const accessContext = normalizeToolAccessContext({ agentId: access?.agentId, enabledServerIds: access?.enabledServerIds, - enabledPluginIds: access?.enabledPluginIds, conversationId: toolCall.conversationId }) const hintedProviderId = toolCall.providerId?.trim() diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index 0f0359854..452a85658 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -96,6 +96,33 @@ const REMOTE_DELIVERY_CHANNELS: readonly RemoteChannel[] = [ 'discord', 'weixin-ilink' ] +const REMOTE_CHANNEL_CATALOG = [ + { + id: 'telegram', + titleKey: 'settings.remote.telegram.title', + descriptionKey: 'settings.remote.telegram.description' + }, + { + id: 'feishu', + titleKey: 'settings.remote.feishu.title', + descriptionKey: 'settings.remote.feishu.description' + }, + { + id: 'qqbot', + titleKey: 'settings.remote.qqbot.title', + descriptionKey: 'settings.remote.qqbot.description' + }, + { + id: 'discord', + titleKey: 'settings.remote.discord.title', + descriptionKey: 'settings.remote.discord.description' + }, + { + id: 'weixin-ilink', + titleKey: 'settings.remote.weixinIlink.title', + descriptionKey: 'settings.remote.weixinIlink.description' + } +] satisfies readonly Omit[] type CronJobRemoteDeliveryInput = { job: CronJob @@ -305,53 +332,10 @@ export class RemoteControlPresenter { } async listRemoteChannels(): Promise { - return [ - { - id: 'telegram', - type: 'builtin', - implemented: true, - titleKey: 'settings.remote.telegram.title', - descriptionKey: 'settings.remote.telegram.description', - supportsPairing: true, - supportsNotifications: false - }, - { - id: 'feishu', - type: 'builtin', - implemented: true, - titleKey: 'settings.remote.feishu.title', - descriptionKey: 'settings.remote.feishu.description', - supportsPairing: true, - supportsNotifications: false - }, - { - id: 'qqbot', - type: 'builtin', - implemented: true, - titleKey: 'settings.remote.qqbot.title', - descriptionKey: 'settings.remote.qqbot.description', - supportsPairing: true, - supportsNotifications: false - }, - { - id: 'discord', - type: 'builtin', - implemented: true, - titleKey: 'settings.remote.discord.title', - descriptionKey: 'settings.remote.discord.description', - supportsPairing: true, - supportsNotifications: false - }, - { - id: 'weixin-ilink', - type: 'builtin', - implemented: true, - titleKey: 'settings.remote.weixinIlink.title', - descriptionKey: 'settings.remote.weixinIlink.description', - supportsPairing: false, - supportsNotifications: false - } - ] + return REMOTE_CHANNEL_CATALOG.map((channel) => ({ + ...channel, + supportsCronDelivery: REMOTE_DELIVERY_CHANNELS.includes(channel.id) + })) } async getChannelSettings(channel: T): Promise { diff --git a/src/main/presenter/skillPresenter/skillTools.ts b/src/main/presenter/skillPresenter/skillTools.ts index b082818ef..7283a783e 100644 --- a/src/main/presenter/skillPresenter/skillTools.ts +++ b/src/main/presenter/skillPresenter/skillTools.ts @@ -12,8 +12,7 @@ export class SkillTools { async handleSkillList( conversationId?: string, allowedSkillNames?: string[] | null, - activeSkillNames?: string[], - allowedPluginIds?: string[] | null + activeSkillNames?: string[] ): Promise<{ skills: SkillListItem[] pinnedCount: number @@ -23,16 +22,9 @@ export class SkillTools { const allowedSkillSet = Array.isArray(allowedSkillNames) ? new Set(allowedSkillNames.map((skillName) => skillName.trim()).filter(Boolean)) : undefined - const allowedPluginSet = Array.isArray(allowedPluginIds) - ? new Set(allowedPluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)) - : undefined - const allSkills = (await this.skillPresenter.getMetadataList()).filter((skill) => { - if (allowedSkillSet && !allowedSkillSet.has(skill.name)) { - return false - } - const ownerPluginId = skill.ownerPluginId?.trim() - return !ownerPluginId || !allowedPluginSet || allowedPluginSet.has(ownerPluginId) - }) + const allSkills = (await this.skillPresenter.getMetadataList()).filter( + (skill) => !allowedSkillSet || allowedSkillSet.has(skill.name) + ) const listedSkillNames = new Set(allSkills.map((skill) => skill.name)) const pinnedSkills = conversationId ? (await this.skillPresenter.getActiveSkills(conversationId)).filter((skillName) => @@ -66,8 +58,7 @@ export class SkillTools { async handleSkillView( conversationId: string | undefined, input: { name: string; file_path?: string }, - allowedSkillNames?: string[] | null, - allowedPluginIds?: string[] | null + allowedSkillNames?: string[] | null ): Promise { const requestedSkillName = input.name.trim() const allowedSkillSet = Array.isArray(allowedSkillNames) @@ -81,23 +72,6 @@ export class SkillTools { } } - if (Array.isArray(allowedPluginIds)) { - const allowedPluginSet = new Set( - allowedPluginIds.map((pluginId) => pluginId.trim()).filter(Boolean) - ) - const metadata = (await this.skillPresenter.getMetadataList()).find( - (skill) => skill.name === requestedSkillName - ) - const ownerPluginId = metadata?.ownerPluginId?.trim() - if (ownerPluginId && !allowedPluginSet.has(ownerPluginId)) { - return { - success: false, - name: requestedSkillName, - error: `Skill '${requestedSkillName}' is not enabled for this agent` - } - } - } - return await this.skillPresenter.viewSkill(requestedSkillName, { filePath: input.file_path, conversationId diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts index a0e22a637..fca92761a 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts @@ -141,20 +141,7 @@ export class DeepChatMessagesTable extends BaseTable { getBySession(sessionId: string): DeepChatMessageRow[] { return this.db - .prepare( - `SELECT - m.*, - COALESCE(t.trace_count, 0) AS trace_count - FROM deepchat_messages m - LEFT JOIN ( - SELECT message_id, COUNT(*) AS trace_count - FROM deepchat_message_traces - GROUP BY message_id - ) t - ON t.message_id = m.id - WHERE m.session_id = ? - ORDER BY m.order_seq` - ) + .prepare('SELECT * FROM deepchat_messages WHERE session_id = ? ORDER BY order_seq') .all(sessionId) as DeepChatMessageRow[] } @@ -240,19 +227,8 @@ export class DeepChatMessagesTable extends BaseTable { } get(messageId: string): DeepChatMessageRow | undefined { - return this.db - .prepare( - `SELECT - m.*, - COALESCE(( - SELECT COUNT(*) - FROM deepchat_message_traces t - WHERE t.message_id = m.id - ), 0) AS trace_count - FROM deepchat_messages m - WHERE m.id = ?` - ) - .get(messageId) as DeepChatMessageRow | undefined + const row = this.db.prepare('SELECT * FROM deepchat_messages WHERE id = ?').get(messageId) + return row as DeepChatMessageRow | undefined } getMaxOrderSeq(sessionId: string): number { diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 30b77d86d..939ac9002 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -106,7 +106,6 @@ interface AgentToolExecutionOptions { allowExternalFileAccess?: boolean activeSkillNames?: string[] enabledSkillNames?: string[] | null - enabledPluginIds?: string[] | null } interface AgentToolPermissionCheckOptions { @@ -2174,14 +2173,12 @@ export class AgentToolManager { const skillTools = this.getSkillTools() const effectiveActiveSkills = this.normalizeActiveSkillOption(options?.activeSkillNames) const enabledSkillNames = this.normalizeNullableSkillOption(options?.enabledSkillNames) - const enabledPluginIds = this.normalizeNullableSkillOption(options?.enabledPluginIds) if (toolName === 'skill_list') { const result = await skillTools.handleSkillList( conversationId, enabledSkillNames, - effectiveActiveSkills, - enabledPluginIds + effectiveActiveSkills ) return { content: JSON.stringify(result) } } @@ -2200,8 +2197,7 @@ export class AgentToolManager { const result = await skillTools.handleSkillView( conversationId, validationResult.data, - enabledSkillNames, - enabledPluginIds + enabledSkillNames ) const normalizedViewedSkill = result.name?.trim() || validationResult.data.name.trim() const activeSkillNamesForResult = effectiveActiveSkills ?? [] diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 5b4c61eb3..3ee49206c 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -59,7 +59,6 @@ export interface IToolPresenter { getAllToolDefinitions(context: { enabledMcpTools?: string[] enabledMcpServerIds?: string[] - enabledPluginIds?: string[] agentId?: string disabledAgentTools?: string[] chatMode?: 'agent' | 'acp agent' @@ -82,7 +81,6 @@ export interface IToolPresenter { enabledSkillNames?: string[] | null agentId?: string enabledMcpServerIds?: string[] - enabledPluginIds?: string[] | null } ): Promise<{ content: unknown; rawData: MCPToolResponse }> preCheckToolPermission?( @@ -144,7 +142,6 @@ const allowsExternalFileAccess = (mode?: PermissionMode): boolean => type StoredMcpAccessContext = { agentId?: string enabledMcpServerIds?: string[] - enabledPluginIds?: string[] } /** @@ -184,7 +181,6 @@ export class ToolPresenter implements IToolPresenter { async getAllToolDefinitions(context: { enabledMcpTools?: string[] enabledMcpServerIds?: string[] - enabledPluginIds?: string[] agentId?: string disabledAgentTools?: string[] chatMode?: 'agent' | 'acp agent' @@ -205,8 +201,7 @@ export class ToolPresenter implements IToolPresenter { const agentWorkspacePath = context.agentWorkspacePath || null this.rememberConversationMcpAccessContext(context.conversationId, { agentId: context.agentId, - enabledMcpServerIds: context.enabledMcpServerIds, - enabledPluginIds: context.enabledPluginIds + enabledMcpServerIds: context.enabledMcpServerIds }) // 1. Get MCP tools @@ -215,7 +210,6 @@ export class ToolPresenter implements IToolPresenter { await this.options.mcpPresenter.getAllToolDefinitions({ enabledTools: context.enabledMcpTools, enabledServerIds: context.enabledMcpServerIds, - enabledPluginIds: context.enabledPluginIds, agentId: context.agentId, conversationId: context.conversationId }) @@ -306,7 +300,6 @@ export class ToolPresenter implements IToolPresenter { enabledSkillNames?: string[] | null agentId?: string enabledMcpServerIds?: string[] - enabledPluginIds?: string[] | null } ): Promise<{ content: unknown; rawData: MCPToolResponse }> { const toolName = request.function.name @@ -349,8 +342,7 @@ export class ToolPresenter implements IToolPresenter { signal: options?.signal, allowExternalFileAccess: allowsExternalFileAccess(options?.permissionMode), activeSkillNames: options?.activeSkillNames, - enabledSkillNames: options?.enabledSkillNames, - enabledPluginIds: options?.enabledPluginIds + enabledSkillNames: options?.enabledSkillNames } ) const resolvedResponse = this.resolveAgentToolResponse(response) @@ -386,8 +378,7 @@ export class ToolPresenter implements IToolPresenter { const storedAccess = this.getConversationMcpAccessContext(request.conversationId) return await this.options.mcpPresenter.callTool(request, { agentId: options?.agentId ?? storedAccess?.agentId, - enabledServerIds: options?.enabledMcpServerIds ?? storedAccess?.enabledMcpServerIds, - enabledPluginIds: options?.enabledPluginIds ?? storedAccess?.enabledPluginIds + enabledServerIds: options?.enabledMcpServerIds ?? storedAccess?.enabledMcpServerIds }) } @@ -454,8 +445,7 @@ export class ToolPresenter implements IToolPresenter { const storedAccess = this.getConversationMcpAccessContext(request.conversationId) return await this.options.mcpPresenter.preCheckToolPermission(request, { agentId: storedAccess?.agentId, - enabledServerIds: storedAccess?.enabledMcpServerIds, - enabledPluginIds: storedAccess?.enabledPluginIds + enabledServerIds: storedAccess?.enabledMcpServerIds }) } @@ -481,8 +471,7 @@ export class ToolPresenter implements IToolPresenter { this.conversationMcpAccessContexts.set(normalizedConversationId, { agentId: context.agentId?.trim() || undefined, - enabledMcpServerIds: normalizeOptionalToolNames(context.enabledMcpServerIds), - enabledPluginIds: normalizeOptionalToolNames(context.enabledPluginIds) + enabledMcpServerIds: normalizeOptionalToolNames(context.enabledMcpServerIds) }) } diff --git a/src/renderer/settings/components/CronJobsSettings.vue b/src/renderer/settings/components/CronJobsSettings.vue index df0e6469b..39490c12e 100644 --- a/src/renderer/settings/components/CronJobsSettings.vue +++ b/src/renderer/settings/components/CronJobsSettings.vue @@ -549,13 +549,13 @@ const handleError = (scope: string, error: unknown) => { }) } -const loadRemoteDeliveryOptions = async (): Promise => { +const loadRemoteDeliveryOptions = async (): Promise => { remoteDeliveryLoading.value = true try { const descriptors = await remoteControlClient.listRemoteChannels() const groups = await Promise.all( descriptors - .filter((descriptor) => descriptor.implemented && descriptor.id !== 'qqbot') + .filter((descriptor) => descriptor.supportsCronDelivery) .map(async (descriptor) => { const status = await remoteControlClient.getChannelStatus(descriptor.id) if (!status.enabled) { @@ -582,7 +582,7 @@ const loadRemoteDeliveryOptions = async (): Promise => { ) } catch (error) { console.error('[CronJobs] Failed to load remote delivery options:', error) - return [] + return null } finally { remoteDeliveryLoading.value = false } @@ -598,7 +598,9 @@ const loadJobs = async () => { ]) jobs.value = sortJobs(response.jobs) agents.value = nextAgents - remoteDeliveryOptions.value = nextRemoteDeliveryOptions + if (nextRemoteDeliveryOptions !== null) { + remoteDeliveryOptions.value = nextRemoteDeliveryOptions + } schedulerStatus.value = response.schedulerStatus for (const job of jobs.value) { void refreshJobPreview(job) diff --git a/src/renderer/settings/components/RemoteSettings.vue b/src/renderer/settings/components/RemoteSettings.vue index 5e880e732..9fe36fd80 100644 --- a/src/renderer/settings/components/RemoteSettings.vue +++ b/src/renderer/settings/components/RemoteSettings.vue @@ -47,10 +47,10 @@ (null) const qqbotStatus = ref(null) const discordStatus = ref(null) const weixinIlinkStatus = ref(null) -const channelDescriptors = ref(fallbackChannelDescriptors) +const channelDescriptors = ref([]) const isLoading = ref(false) const showBotToken = ref(false) const showDiscordBotToken = ref(false) @@ -2080,10 +2032,6 @@ const normalizeDiscordPairingSnapshot = ( pairedChannelIds: [...(snapshot?.pairedChannelIds ?? [])] }) -const listRemoteChannelsCompat = async (): Promise => { - return await remoteControlClient.listRemoteChannels() -} - function getChannelSettingsCompat(channel: 'telegram'): Promise function getChannelSettingsCompat(channel: 'feishu'): Promise function getChannelSettingsCompat(channel: 'qqbot'): Promise @@ -2259,17 +2207,13 @@ const resolveWeixinIlinkLoginMessage = (input: { messageKey?: string | null }): string => resolveRemoteMessage(input, 'settings.remote.weixinIlink.loginFailed') -const implementedChannels = computed(() => - channelDescriptors.value - .filter((descriptor) => descriptor.implemented) - .map((descriptor) => descriptor.id) -) -const implementedChannelCount = computed(() => Math.max(1, implementedChannels.value.length)) +const remoteChannelIds = computed(() => channelDescriptors.value.map((descriptor) => descriptor.id)) +const remoteChannelCount = computed(() => Math.max(1, remoteChannelIds.value.length)) const rootComponent = computed(() => (props.embedded ? 'div' : ScrollArea)) const singleChannelMode = computed(() => Boolean(props.singleChannel || props.channel)) const isRemoteChannel = (value: unknown): value is RemoteChannel => typeof value === 'string' && - fallbackChannelDescriptors.some((descriptor) => descriptor.id === value) + channelDescriptors.value.some((descriptor) => descriptor.id === value) const syncActiveChannelFromProps = () => { if (props.channel && isRemoteChannel(props.channel)) { activeChannel.value = props.channel @@ -2650,7 +2594,7 @@ const loadState = async () => { loadedDiscordStatus, loadedWeixinIlinkStatus ] = await Promise.all([ - listRemoteChannelsCompat(), + remoteControlClient.listRemoteChannels(), getChannelSettingsCompat('telegram'), getChannelSettingsCompat('feishu'), getChannelSettingsCompat('qqbot'), @@ -2665,8 +2609,7 @@ const loadState = async () => { loadRecentProjects() ]) - channelDescriptors.value = - loadedChannelDescriptors.length > 0 ? loadedChannelDescriptors : fallbackChannelDescriptors + channelDescriptors.value = loadedChannelDescriptors syncTelegramFields(loadedTelegramSettings) syncFeishuFields(loadedFeishuSettings) syncQQBotFields(loadedQQBotSettings) @@ -2679,8 +2622,8 @@ const loadState = async () => { weixinIlinkStatus.value = loadedWeixinIlinkStatus syncActiveChannelFromProps() - if (!implementedChannels.value.includes(activeChannel.value)) { - activeChannel.value = implementedChannels.value[0] ?? 'telegram' + if (!remoteChannelIds.value.includes(activeChannel.value)) { + activeChannel.value = remoteChannelIds.value[0] ?? 'telegram' } scheduleStatusRefresh( hasEnabledRemoteSettings() ? REMOTE_STATUS_ACTIVE_POLL_MS : REMOTE_STATUS_IDLE_POLL_MS diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 0f250659c..03082226f 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -515,6 +515,7 @@ diff --git a/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue b/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue index a06d3c011..98dae8b98 100644 --- a/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue +++ b/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue @@ -181,36 +181,19 @@ {{ errorMessage }} -
-
-
{{ t('settings.plugins.runtime') }}
-
-
{{ t('settings.plugins.runtime') }}
-
{{ formatRuntimeState(plugin.runtime?.state) }}
-
{{ t('settings.plugins.version') }}
-
{{ plugin.runtime?.version || '-' }}
-
{{ t('settings.plugins.command') }}
-
{{ plugin.runtime?.command || '-' }}
-
-

- {{ plugin.runtime.lastError }} -

-
- -
-
- {{ t('settings.pluginsHub.capabilities') }} -
-
- - {{ capability }} - -
-
+
+
{{ t('settings.plugins.runtime') }}
+
+
{{ t('settings.plugins.runtimeState') }}
+
{{ formatRuntimeState(plugin.runtime?.state) }}
+
{{ t('settings.plugins.version') }}
+
{{ plugin.runtime?.version || '-' }}
+
{{ t('settings.plugins.command') }}
+
{{ plugin.runtime?.command || '-' }}
+
+

+ {{ plugin.runtime.lastError }} +

@@ -268,30 +251,24 @@ import { Button } from '@shadcn/components/ui/button' import { ScrollArea } from '@shadcn/components/ui/scroll-area' import { createPluginClient } from '@api/PluginClient' import { createRemoteControlClient } from '@api/RemoteControlClient' +import { usePluginCatalogStore } from '@/stores/pluginCatalog' import RemoteSettings from '../../../settings/components/RemoteSettings.vue' -import type { - ChannelSettingsMap, - RemoteChannel, - RemoteChannelSettings, - RemoteChannelStatus -} from '@shared/presenter' -import type { PluginActionResult, PluginListItem, PluginRuntimeState } from '@shared/types/plugin' +import type { ChannelSettingsMap, RemoteChannel } from '@shared/presenter' +import type { PluginActionResult, PluginRuntimeState } from '@shared/types/plugin' const { t } = useI18n() const route = useRoute() const router = useRouter() const pluginClient = createPluginClient() const remoteControlClient = createRemoteControlClient() +const pluginCatalogStore = usePluginCatalogStore() -const plugin = ref(null) const loading = ref(false) const remoteLoading = ref(false) const pending = ref(false) const errorMessage = ref('') const remoteErrorMessage = ref('') const lastActionData = ref('') -const remoteSettings = ref(null) -const remoteStatus = ref(null) const remoteSettingsVersion = ref(0) const FEISHU_PLUGIN_ID = 'com.deepchat.plugins.feishu' const remoteI18nKeyByChannel: Record = { @@ -330,9 +307,14 @@ const remoteChannel = computed(() => { ? (channel as RemoteChannel) : null }) +const plugin = computed(() => pluginCatalogStore.getPlugin(pluginId.value)) +const remoteStatus = computed(() => { + const channel = remoteChannel.value + return channel ? (pluginCatalogStore.remoteStatuses[channel] ?? null) : null +}) const isFeishuPlugin = computed(() => pluginId.value === FEISHU_PLUGIN_ID) const isCuaPlugin = computed(() => pluginId.value === CUA_PLUGIN_ID) -const remoteEnabled = computed(() => Boolean(remoteSettings.value?.remoteEnabled)) +const remoteEnabled = computed(() => Boolean(remoteStatus.value?.enabled)) const remoteTitle = computed(() => { const channel = remoteChannel.value return channel ? t(`settings.remote.${remoteI18nKeyByChannel[channel]}.title`) : '' @@ -380,15 +362,17 @@ function formatRuntimeState(state?: PluginRuntimeState): string { async function loadPlugin(): Promise { if (!pluginId.value) { - plugin.value = null return } - loading.value = true + loading.value = !plugin.value errorMessage.value = '' + const version = pluginCatalogStore.capturePluginRefresh() try { - plugin.value = (await pluginClient.getPlugin(pluginId.value)) ?? null + const nextPlugin = await pluginClient.getPlugin(pluginId.value) + if (nextPlugin) { + pluginCatalogStore.replacePlugin(nextPlugin, version) + } } catch (error) { - plugin.value = null errorMessage.value = error instanceof Error ? error.message : t('settings.plugins.loadFailed') } finally { loading.value = false @@ -398,27 +382,23 @@ async function loadPlugin(): Promise { async function loadRemotePlugin(): Promise { const channel = remoteChannel.value if (!channel) { - remoteSettings.value = null - remoteStatus.value = null return } - plugin.value = null - remoteLoading.value = true + remoteLoading.value = !remoteStatus.value remoteErrorMessage.value = '' errorMessage.value = '' + const version = pluginCatalogStore.captureRemoteRefresh() try { - const [settings, status] = await Promise.all([ - remoteControlClient.getChannelSettings(channel), - remoteControlClient.getChannelStatus(channel) - ]) - remoteSettings.value = settings - remoteStatus.value = status + const status = await remoteControlClient.getChannelStatus(channel) + pluginCatalogStore.replaceRemoteStatus(status, version) } catch (error) { - remoteSettings.value = null - remoteStatus.value = null - remoteErrorMessage.value = - error instanceof Error ? error.message : t('common.error.requestFailed') + if (remoteStatus.value) { + console.warn(`[OfficialPluginDetailPage] Failed to refresh ${channel} status:`, error) + } else { + remoteErrorMessage.value = + error instanceof Error ? error.message : t('common.error.requestFailed') + } } finally { remoteLoading.value = false } @@ -430,23 +410,37 @@ async function loadCurrentDetail(): Promise { return } - remoteSettings.value = null - remoteStatus.value = null await loadPlugin() } -async function runPluginAction(action: () => Promise): Promise { +async function runPluginAction( + enabled: boolean, + action: () => Promise, + afterSuccess?: () => Promise +): Promise { + const currentPlugin = plugin.value + if (!currentPlugin) { + return + } + pending.value = true errorMessage.value = '' lastActionData.value = '' + const previous = pluginCatalogStore.beginPluginEnabledMutation(currentPlugin.id, enabled) + let pluginActionSucceeded = false try { const result = await action() if (!result.ok) { throw new Error(result.error || t('settings.plugins.actionFailed')) } + pluginActionSucceeded = true + pluginCatalogStore.commitPluginMutation(result.status) lastActionData.value = result.data ? JSON.stringify(result.data, null, 2) : '' - await loadPlugin() + await afterSuccess?.() } catch (error) { + if (!pluginActionSucceeded) { + pluginCatalogStore.rollbackPluginMutation(previous) + } errorMessage.value = error instanceof Error ? error.message : t('settings.plugins.actionFailed') } finally { pending.value = false @@ -457,15 +451,20 @@ async function setRemoteChannelEnabled( channel: T, remoteEnabled: boolean ): Promise { - const settings = await remoteControlClient.getChannelSettings(channel) - if (settings.remoteEnabled === remoteEnabled) { - return + const previous = pluginCatalogStore.beginRemoteEnabledMutation(channel, remoteEnabled) + try { + const settings = await remoteControlClient.getChannelSettings(channel) + if (settings.remoteEnabled !== remoteEnabled) { + await remoteControlClient.saveChannelSettings(channel, { + ...settings, + remoteEnabled + } as ChannelSettingsMap[T]) + } + pluginCatalogStore.commitRemoteMutation(await remoteControlClient.getChannelStatus(channel)) + } catch (error) { + pluginCatalogStore.rollbackRemoteMutation(channel, previous) + throw error } - - await remoteControlClient.saveChannelSettings(channel, { - ...settings, - remoteEnabled - } as ChannelSettingsMap[T]) } async function setFeishuRemoteEnabled(remoteEnabled: boolean): Promise { @@ -477,7 +476,6 @@ async function runRemoteAction(action: () => Promise): Promise { errorMessage.value = '' try { await action() - await loadRemotePlugin() remoteSettingsVersion.value += 1 } catch (error) { errorMessage.value = error instanceof Error ? error.message : t('settings.plugins.actionFailed') @@ -491,14 +489,16 @@ function enablePlugin(): void { if (!currentPlugin) { return } - void runPluginAction(async () => { - const result = await pluginClient.enablePlugin(currentPlugin.id) - if (result.ok && currentPlugin.id === FEISHU_PLUGIN_ID) { - await setFeishuRemoteEnabled(true) - remoteSettingsVersion.value += 1 - } - return result - }) + void runPluginAction( + true, + () => pluginClient.enablePlugin(currentPlugin.id), + currentPlugin.id === FEISHU_PLUGIN_ID + ? async () => { + await setFeishuRemoteEnabled(true) + remoteSettingsVersion.value += 1 + } + : undefined + ) } function disablePlugin(): void { @@ -506,14 +506,16 @@ function disablePlugin(): void { if (!currentPlugin) { return } - void runPluginAction(async () => { - const result = await pluginClient.disablePlugin(currentPlugin.id) - if (result.ok && currentPlugin.id === FEISHU_PLUGIN_ID) { - await setFeishuRemoteEnabled(false) - remoteSettingsVersion.value += 1 - } - return result - }) + void runPluginAction( + false, + () => pluginClient.disablePlugin(currentPlugin.id), + currentPlugin.id === FEISHU_PLUGIN_ID + ? async () => { + await setFeishuRemoteEnabled(false) + remoteSettingsVersion.value += 1 + } + : undefined + ) } function enableRemotePlugin(): void { diff --git a/src/renderer/src/pages/plugins/PluginsCatalogPage.vue b/src/renderer/src/pages/plugins/PluginsCatalogPage.vue index 773d17c38..5d6fd9041 100644 --- a/src/renderer/src/pages/plugins/PluginsCatalogPage.vue +++ b/src/renderer/src/pages/plugins/PluginsCatalogPage.vue @@ -21,8 +21,6 @@ {{ errorMessage }} - -

{{ t('settings.pluginsHub.available') }}

@@ -83,6 +81,7 @@ diff --git a/src/renderer/src/stores/pluginCatalog.ts b/src/renderer/src/stores/pluginCatalog.ts new file mode 100644 index 000000000..66af3d0c6 --- /dev/null +++ b/src/renderer/src/stores/pluginCatalog.ts @@ -0,0 +1,149 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { RemoteChannel, RemoteChannelDescriptor, RemoteChannelStatus } from '@shared/presenter' +import type { PluginListItem } from '@shared/types/plugin' + +type RemoteStatusCache = Partial> + +export const usePluginCatalogStore = defineStore('pluginCatalog', () => { + const plugins = ref([]) + const remoteChannels = ref([]) + const remoteStatuses = ref({}) + let pluginMutationVersion = 0 + let remoteMutationVersion = 0 + + const getPlugin = (pluginId: string): PluginListItem | null => + plugins.value.find((plugin) => plugin.id === pluginId) ?? null + + const upsertPlugin = (plugin: PluginListItem) => { + const index = plugins.value.findIndex((item) => item.id === plugin.id) + plugins.value = + index < 0 + ? [...plugins.value, plugin] + : plugins.value.map((item) => (item.id === plugin.id ? plugin : item)) + } + + const capturePluginRefresh = (): number => pluginMutationVersion + + const replacePlugins = (nextPlugins: PluginListItem[], version: number): boolean => { + if (version !== pluginMutationVersion) { + return false + } + plugins.value = nextPlugins + return true + } + + const replacePlugin = (plugin: PluginListItem, version: number): boolean => { + if (version !== pluginMutationVersion) { + return false + } + upsertPlugin(plugin) + return true + } + + const beginPluginEnabledMutation = ( + pluginId: string, + enabled: boolean + ): PluginListItem | null => { + const previous = getPlugin(pluginId) + pluginMutationVersion += 1 + if (previous) { + upsertPlugin({ ...previous, enabled }) + } + return previous + } + + const commitPluginMutation = (plugin?: PluginListItem) => { + pluginMutationVersion += 1 + if (plugin) { + upsertPlugin(plugin) + } + } + + const rollbackPluginMutation = (previous: PluginListItem | null) => { + pluginMutationVersion += 1 + if (previous) { + upsertPlugin(previous) + } + } + + const captureRemoteRefresh = (): number => remoteMutationVersion + + const replaceRemoteSnapshot = ( + channels: RemoteChannelDescriptor[], + statuses: RemoteChannelStatus[], + version: number + ): boolean => { + if (version !== remoteMutationVersion) { + return false + } + remoteChannels.value = channels + remoteStatuses.value = Object.fromEntries( + statuses.map((status) => [status.channel, status]) + ) as RemoteStatusCache + return true + } + + const replaceRemoteStatus = (status: RemoteChannelStatus, version: number): boolean => { + if (version !== remoteMutationVersion) { + return false + } + remoteStatuses.value = { ...remoteStatuses.value, [status.channel]: status } + return true + } + + const beginRemoteEnabledMutation = ( + channel: RemoteChannel, + enabled: boolean + ): RemoteChannelStatus | null => { + const previous = remoteStatuses.value[channel] ?? null + remoteMutationVersion += 1 + if (previous) { + remoteStatuses.value = { + ...remoteStatuses.value, + [channel]: { + ...previous, + enabled, + state: enabled ? 'starting' : 'disabled' + } + } + } + return previous + } + + const commitRemoteMutation = (status: RemoteChannelStatus) => { + remoteMutationVersion += 1 + remoteStatuses.value = { ...remoteStatuses.value, [status.channel]: status } + } + + const rollbackRemoteMutation = (channel: RemoteChannel, previous: RemoteChannelStatus | null) => { + remoteMutationVersion += 1 + if (previous) { + remoteStatuses.value = { ...remoteStatuses.value, [channel]: previous } + return + } + + const nextStatuses = { ...remoteStatuses.value } + delete nextStatuses[channel] + remoteStatuses.value = nextStatuses + } + + return { + plugins, + remoteChannels, + remoteStatuses, + getPlugin, + capturePluginRefresh, + replacePlugins, + replacePlugin, + beginPluginEnabledMutation, + commitPluginMutation, + rollbackPluginMutation, + captureRemoteRefresh, + replaceRemoteSnapshot, + replaceRemoteStatus, + beginRemoteEnabledMutation, + commitRemoteMutation, + rollbackRemoteMutation + } +}) diff --git a/src/shared/contracts/domainSchemas.ts b/src/shared/contracts/domainSchemas.ts index 11adb19b3..706059b44 100644 --- a/src/shared/contracts/domainSchemas.ts +++ b/src/shared/contracts/domainSchemas.ts @@ -678,7 +678,6 @@ export const DeepChatAgentConfigSchema = z.looseObject({ systemPrompt: z.string().optional(), permissionMode: z.enum(['default', 'auto_approve', 'full_access']).optional(), disabledAgentTools: z.array(z.string()).optional(), - enabledPluginIds: z.array(z.string()).nullable().optional(), enabledSkillNames: z.array(z.string()).nullable().optional(), enabledMcpServerIds: z.array(z.string()).nullable().optional(), subagentEnabled: z.boolean().optional(), diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 6b46eb0ae..d78d5f6c7 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -732,7 +732,6 @@ export interface DeepChatAgentConfig { systemPrompt?: string permissionMode?: PermissionMode disabledAgentTools?: string[] - enabledPluginIds?: string[] | null enabledSkillNames?: string[] | null enabledMcpServerIds?: string[] | null subagentEnabled?: boolean diff --git a/src/shared/types/presenters/core.presenter.d.ts b/src/shared/types/presenters/core.presenter.d.ts index 3a80af0a9..f5bc859d9 100644 --- a/src/shared/types/presenters/core.presenter.d.ts +++ b/src/shared/types/presenters/core.presenter.d.ts @@ -1801,7 +1801,6 @@ export interface IMCPPresenter { | { enabledTools?: string[] enabledServerIds?: string[] - enabledPluginIds?: string[] agentId?: string conversationId?: string } @@ -1822,7 +1821,6 @@ export interface IMCPPresenter { signal?: AbortSignal agentId?: string enabledServerIds?: string[] - enabledPluginIds?: string[] } ): Promise<{ content: string; rawData: MCPToolResponse }> preCheckToolPermission?( @@ -1830,7 +1828,6 @@ export interface IMCPPresenter { options?: { agentId?: string enabledServerIds?: string[] - enabledPluginIds?: string[] } ): Promise<{ needsPermission: true diff --git a/src/shared/types/presenters/remote-control.presenter.d.ts b/src/shared/types/presenters/remote-control.presenter.d.ts index 74c2cd65b..beb9cd8d0 100644 --- a/src/shared/types/presenters/remote-control.presenter.d.ts +++ b/src/shared/types/presenters/remote-control.presenter.d.ts @@ -17,12 +17,9 @@ export type RemoteRuntimeState = export interface RemoteChannelDescriptor { id: RemoteChannelId - type: 'builtin' - implemented: boolean titleKey: string descriptionKey: string - supportsPairing: boolean - supportsNotifications: boolean + supportsCronDelivery: boolean } export interface RemoteBindingSummary { diff --git a/src/shared/types/presenters/tool.presenter.d.ts b/src/shared/types/presenters/tool.presenter.d.ts index 2a27ebb61..7c5041b6a 100644 --- a/src/shared/types/presenters/tool.presenter.d.ts +++ b/src/shared/types/presenters/tool.presenter.d.ts @@ -32,7 +32,6 @@ export interface IToolPresenter { getAllToolDefinitions(context: { enabledMcpTools?: string[] enabledMcpServerIds?: string[] - enabledPluginIds?: string[] agentId?: string disabledAgentTools?: string[] chatMode?: 'agent' | 'acp agent' @@ -64,7 +63,6 @@ export interface IToolPresenter { enabledSkillNames?: string[] | null agentId?: string enabledMcpServerIds?: string[] - enabledPluginIds?: string[] | null } ): Promise<{ content: unknown; rawData: MCPToolResponse }> diff --git a/src/types/i18n.d.ts b/src/types/i18n.d.ts index a590e5475..92636220f 100644 --- a/src/types/i18n.d.ts +++ b/src/types/i18n.d.ts @@ -1026,6 +1026,85 @@ declare module 'vue-i18n' { similarityThresholdHint: string weightHint: string } + redesign: { + tabMemories: string + tabPersona: string + tabDiagnostics: string + statusEnabled: string + statusDisabled: string + memoryCount: string + embeddingModel: string + embeddingMissing: string + embeddingMissingHint: string + enableMemory: string + configure: string + emptyTitle: string + disabledTitle: string + emptyDescription: string + disabledDescription: string + addMemory: string + loadMore: string + configTitle: string + configDescription: string + relativeWeightsHint: string + configLoadFailed: string + configSaveFailed: string + detailTitle: string + createdAt: string + unsavedTitle: string + unsavedDescription: string + discardChanges: string + contentLabel: string + contentPlaceholder: string + categoryLabel: string + importanceLabel: string + kindLine: string + statusLine: string + archivedEditHint: string + editRejected: string + sourceConversation: string + sourceManual: string + lifecycleDetails: string + archive: string + includeArchived: string + archivedMatches: string + refresh: string + inboxTitle: string + inboxDescription: string + conflictBadge: string + personaDraftBadge: string + conflictSectionTitle: string + conflictExisting: string + conflictNew: string + personaDraftSectionTitle: string + diagnosticsTitle: string + diagnosticsDescription: string + pipelineTitle: string + archiveCandidatesTitle: string + archiveCandidatesDescription: string + recentFailuresTitle: string + activityTitle: string + dangerZoneTitle: string + dangerZoneDescription: string + audit: { + 'memory-add': string + 'memory-archive': string + 'memory-restore': string + 'memory-delete': string + 'memory-maintenance-llm': string + 'memory-reflect': string + 'persona-evolve': string + 'memory-repair': string + 'memory-forget': string + 'memory-manual-edit': string + 'memory-challenge-resolved': string + 'memory-persona-approve': string + 'memory-persona-reject': string + 'memory-persona-rollback': string + 'memory-persona-anchor': string + 'memory-reindex': string + } + } } loading: string copied: string @@ -1753,6 +1832,7 @@ declare module 'vue-i18n' { disable: string openSettings: string runtime: string + runtimeState: string version: string command: string status: { @@ -2037,6 +2117,8 @@ declare module 'vue-i18n' { byCategory: string byStatus: string pipeline: string + reindex: string + reindexing: string quality: string topAccessed: string noTopAccessed: string @@ -2905,6 +2987,15 @@ declare module 'vue-i18n' { custom: string } } + authRequired: string + authenticate: string + authFailed: string + authCallbackTitle: string + authCallbackDescription: string + authCallbackPlaceholder: string + completeAuthentication: string + saveSuccess: string + saveFailed: string } cronJobs: { title: string @@ -3494,27 +3585,11 @@ declare module 'vue-i18n' { manage: string emptySearch: string pluginNotFound: string - capabilities: string actionResult: string cuaDescription: string - agentScopeTitle: string - agentScopeDescription: string - agentScopeNoAgent: string + acpUnavailableTitle: string + acpUnavailableDescription: string agentScopeUnsupported: string - agentScopeRefresh: string - agentScopePlugins: string - agentScopeSkills: string - agentScopeMcp: string - agentScopeInherited: string - agentScopeCustom: string - agentScopeInheritedSummary: string - agentScopeDenyAllSummary: string - agentScopeSelectedSummary: string - agentScopeSelectAll: string - agentScopeClearAll: string - agentScopeNoPlugins: string - agentScopeNoSkills: string - agentScopeNoMcp: string } controlCenter: { groups: { diff --git a/test/e2e/specs/11-remote-control-readonly-route.smoke.spec.ts b/test/e2e/specs/11-remote-control-readonly-route.smoke.spec.ts index c74b02ac7..55029922a 100644 --- a/test/e2e/specs/11-remote-control-readonly-route.smoke.spec.ts +++ b/test/e2e/specs/11-remote-control-readonly-route.smoke.spec.ts @@ -24,8 +24,7 @@ test('remote settings read-only routes match visible channel tabs @smoke', async const listed = (await window.deepchat.invoke('remoteControl.listChannels', {})) as { channels?: Array<{ id?: unknown - implemented?: unknown - supportsPairing?: unknown + supportsCronDelivery?: unknown }> } @@ -62,8 +61,7 @@ test('remote settings read-only routes match visible channel tabs @smoke', async return { channels: listed.channels?.map((channel) => ({ id: channel.id, - implemented: channel.implemented, - supportsPairing: channel.supportsPairing + supportsCronDelivery: channel.supportsCronDelivery })), perChannel } @@ -71,8 +69,7 @@ test('remote settings read-only routes match visible channel tabs @smoke', async expect(routeSnapshot.channels?.map((channel) => channel.id)).toEqual([...expectedChannels]) for (const channel of routeSnapshot.channels ?? []) { - expect(channel.implemented).toBe(true) - expect(typeof channel.supportsPairing).toBe('boolean') + expect(channel.supportsCronDelivery).toBe(channel.id !== 'qqbot') } for (const snapshot of routeSnapshot.perChannel) { diff --git a/test/main/performance/memory/messageHistoryTraceScale.perf.ts b/test/main/performance/memory/messageHistoryTraceScale.perf.ts new file mode 100644 index 000000000..734969436 --- /dev/null +++ b/test/main/performance/memory/messageHistoryTraceScale.perf.ts @@ -0,0 +1,97 @@ +import { expect } from 'vitest' + +import { DeepChatMessagesTable } from '@/presenter/sqlitePresenter/tables/deepchatMessages' +import { DeepChatMessageTracesTable } from '@/presenter/sqlitePresenter/tables/deepchatMessageTraces' + +import { describeIfNativeSqlite, requireDatabase } from '../../nativeSqliteHarness' +import { measurePerformance, reportPerformance } from './timing' + +const TARGET_SESSION_ID = 'target-session' +const TARGET_MESSAGE_COUNT = 1_000 +const GLOBAL_TRACE_COUNTS = [0, 10_000, 100_000] as const + +describeIfNativeSqlite('Message history trace scale', () => { + it('keeps runtime history reads independent of unrelated global traces', () => { + const DatabaseCtor = requireDatabase() + const db = new DatabaseCtor(':memory:') + try { + const messages = new DeepChatMessagesTable(db) + messages.createTable() + const traces = new DeepChatMessageTracesTable(db) + traces.createTable() + + const insertMessage = db.prepare( + `INSERT INTO deepchat_messages ( + id, session_id, order_seq, role, content, status, is_context_edge, metadata, + created_at, updated_at + ) VALUES (?, ?, ?, ?, '{}', 'sent', 0, '{}', ?, ?)` + ) + db.transaction(() => { + for (let index = 0; index < TARGET_MESSAGE_COUNT; index += 1) { + const orderSeq = index + 1 + insertMessage.run( + `target-message-${orderSeq}`, + TARGET_SESSION_ID, + orderSeq, + index % 2 === 0 ? 'user' : 'assistant', + orderSeq, + orderSeq + ) + } + })() + + const insertTrace = db.prepare( + `INSERT INTO deepchat_message_traces ( + id, message_id, session_id, provider_id, model_id, request_seq, endpoint, + headers_json, body_json, truncated, created_at + ) VALUES (?, ?, ?, 'openai', 'gpt-4o', 1, 'https://api.openai.test/v1/responses', + '{}', '{}', 0, ?)` + ) + let insertedTraceCount = 0 + const reports: Array<{ traceCount: number; medianMs: number }> = [] + + for (const traceCount of GLOBAL_TRACE_COUNTS) { + db.transaction(() => { + while (insertedTraceCount < traceCount) { + const traceIndex = insertedTraceCount + 1 + insertTrace.run( + `global-trace-${traceIndex}`, + `global-message-${traceIndex}`, + `unrelated-session-${Math.floor(insertedTraceCount / 1_000)}`, + traceIndex + ) + insertedTraceCount = traceIndex + } + })() + + const rows = messages.getBySession(TARGET_SESSION_ID) + expect(rows).toHaveLength(TARGET_MESSAGE_COUNT) + expect(rows.every((row) => row.trace_count === undefined)).toBe(true) + + const report = measurePerformance( + `message-history-global-traces-${traceCount}`, + traceCount, + () => { + messages.getBySession(TARGET_SESSION_ID) + }, + 9 + ) + reportPerformance(report) + reports.push({ traceCount, medianMs: report.medianMs }) + } + + const baselineMedianMs = reports[0]!.medianMs + console.info( + `[message-history-trace-scale] ${JSON.stringify( + reports.map((report) => ({ + ...report, + ratioToZeroTraces: baselineMedianMs === 0 ? 0 : report.medianMs / baselineMedianMs + })) + )}` + ) + expect(reports.map((report) => report.traceCount)).toEqual([...GLOBAL_TRACE_COUNTS]) + } finally { + db.close() + } + }) +}) diff --git a/test/main/presenter/agentRepository.test.ts b/test/main/presenter/agentRepository.test.ts index 9a2b9dd49..0a8c6b89d 100644 --- a/test/main/presenter/agentRepository.test.ts +++ b/test/main/presenter/agentRepository.test.ts @@ -451,7 +451,7 @@ describe('AgentRepository', () => { ).toBe(2000) }) - it('inherits extension policies from builtin and lets custom agents override with empty arrays', () => { + it('inherits skill and MCP policies while ignoring historical plugin policies', () => { const now = Date.now() const makeRow = (id: string, source: string, config: object) => ({ id, @@ -493,16 +493,19 @@ describe('AgentRepository', () => { } } as never) - expect(repository.resolveDeepChatAgentConfig('inheriting-agent')).toMatchObject({ - enabledPluginIds: ['plugin-a'], + const inheritedConfig = repository.resolveDeepChatAgentConfig('inheriting-agent') + const overriddenConfig = repository.resolveDeepChatAgentConfig('overriding-agent') + + expect(inheritedConfig).toMatchObject({ enabledSkillNames: ['skill-a'], enabledMcpServerIds: ['server-a'] }) - expect(repository.resolveDeepChatAgentConfig('overriding-agent')).toMatchObject({ - enabledPluginIds: [], + expect(overriddenConfig).toMatchObject({ enabledSkillNames: ['skill-b'], enabledMcpServerIds: [] }) + expect(inheritedConfig).not.toHaveProperty('enabledPluginIds') + expect(overriddenConfig).not.toHaveProperty('enabledPluginIds') }) it('clears registry ACP installation state without deleting the row', () => { diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index 94305853b..c7efe43bd 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -2866,10 +2866,9 @@ describe('AgentRuntimePresenter', () => { expect(toolPresenter.getAllToolDefinitions).toHaveBeenCalledTimes(2) }) - it('ignores historical agent MCP server allowlists for session tool discovery', async () => { + it('omits historical MCP and plugin policies from session tool discovery', async () => { configPresenter.resolveDeepChatAgentConfig.mockResolvedValue({ enabledMcpServerIds: [], - enabledPluginIds: ['plugin-a'], enabledSkillNames: ['skill-a'] }) @@ -2878,7 +2877,7 @@ describe('AgentRuntimePresenter', () => { const toolContext = toolPresenter.getAllToolDefinitions.mock.calls[0][0] expect(toolContext).not.toHaveProperty('enabledMcpServerIds') - expect(toolContext.enabledPluginIds).toEqual(['plugin-a']) + expect(toolContext).not.toHaveProperty('enabledPluginIds') }) it('invalidates cached prompt after system prompt update', async () => { diff --git a/test/main/presenter/agentRuntimePresenter/messageStore.test.ts b/test/main/presenter/agentRuntimePresenter/messageStore.test.ts index a39aae4ef..e55aca96a 100644 --- a/test/main/presenter/agentRuntimePresenter/messageStore.test.ts +++ b/test/main/presenter/agentRuntimePresenter/messageStore.test.ts @@ -352,7 +352,6 @@ describe('DeepChatMessageStore', () => { status: 'sent', is_context_edge: 0, metadata: '{}', - trace_count: 2, created_at: 1000, updated_at: 1000 } @@ -369,7 +368,7 @@ describe('DeepChatMessageStore', () => { status: 'sent', isContextEdge: 0, metadata: '{}', - traceCount: 2, + traceCount: 0, createdAt: 1000, updatedAt: 1000 }) @@ -497,7 +496,7 @@ describe('DeepChatMessageStore', () => { }) describe('getMessage', () => { - it('returns mapped record when found', () => { + it('uses the trace-free runtime projection', () => { sqlitePresenter.deepchatMessagesTable.get.mockReturnValue({ id: 'm1', session_id: 's1', @@ -514,6 +513,7 @@ describe('DeepChatMessageStore', () => { const msg = store.getMessage('m1') expect(msg).not.toBeNull() expect(msg!.sessionId).toBe('s1') + expect(msg!.traceCount).toBe(0) }) it('returns null when not found', () => { diff --git a/test/main/presenter/mcpPresenter.test.ts b/test/main/presenter/mcpPresenter.test.ts index 3f0c7a23a..1c07bb7ad 100644 --- a/test/main/presenter/mcpPresenter.test.ts +++ b/test/main/presenter/mcpPresenter.test.ts @@ -387,7 +387,7 @@ describe('McpPresenter#setMcpServerEnabled', () => { expect(tools.map((tool) => tool.function.name)).toEqual(['plugin_tool']) }) - it('gates source plugin tools by plugin policy before server policy', async () => { + it('keeps source plugin tools available outside normal server policy', async () => { const configPresenter = createConfigPresenter(true, false, { plugin: { enabled: true, source: 'plugin', sourceId: 'plugin-a' } }) @@ -407,17 +407,9 @@ describe('McpPresenter#setMcpServerEnabled', () => { getAllToolDefinitions: toolManagerMocks.getAllToolDefinitions } - const blockedTools = await presenter.getAllToolDefinitions({ - enabledServerIds: ['plugin'], - enabledPluginIds: [] - }) - const allowedTools = await presenter.getAllToolDefinitions({ - enabledServerIds: [], - enabledPluginIds: ['plugin-a'] - }) + const tools = await presenter.getAllToolDefinitions({ enabledServerIds: [] }) - expect(blockedTools).toEqual([]) - expect(allowedTools.map((tool) => tool.function.name)).toEqual(['plugin_tool']) + expect(tools.map((tool) => tool.function.name)).toEqual(['plugin_tool']) }) it('rejects when the runtime transition fails after persisting config', async () => { diff --git a/test/main/presenter/mcpPresenter/toolManager.test.ts b/test/main/presenter/mcpPresenter/toolManager.test.ts index d460dc446..2a952dc7a 100644 --- a/test/main/presenter/mcpPresenter/toolManager.test.ts +++ b/test/main/presenter/mcpPresenter/toolManager.test.ts @@ -219,7 +219,7 @@ describe('ToolManager', () => { expect(configPresenter.getAgentMcpSelections).toHaveBeenCalledWith('agent-1') }) - it('filters DeepChat MCP tool definitions by enabled server and plugin policies', async () => { + it('filters normal MCP definitions while keeping plugin-owned definitions available', async () => { const normalClient = createClient('server-a') const blockedClient = createClient('server-b') const pluginClient = createClient('plugin-server', undefined, { @@ -234,8 +234,7 @@ describe('ToolManager', () => { const definitions = await manager.getAllToolDefinitions({ agentId: 'agent-1', - enabledServerIds: ['server-a'], - enabledPluginIds: ['plugin-a'] + enabledServerIds: ['server-a'] }) expect(definitions.map((tool) => tool.server.name).sort()).toEqual([ @@ -244,28 +243,45 @@ describe('ToolManager', () => { ]) }) - it('gates source plugin MCP servers by plugin policy instead of server policy', async () => { + it('keeps source plugin MCP servers available outside normal server policy', async () => { const pluginClient = createClient('plugin-source-server', undefined, { source: 'plugin', sourceId: 'plugin-b' }) const configPresenter = createConfigPresenter('plugin-source-server') + configPresenter.getMcpServers.mockResolvedValue({ + 'plugin-source-server': { + autoApprove: ['all'], + source: 'plugin', + sourceId: 'plugin-b' + } + }) const manager = new ToolManager( configPresenter as never, createServerManager([pluginClient]) as never ) - const blockedDefinitions = await manager.getAllToolDefinitions({ - enabledServerIds: ['plugin-source-server'], - enabledPluginIds: [] - }) - const allowedDefinitions = await manager.getAllToolDefinitions({ - enabledServerIds: [], - enabledPluginIds: ['plugin-b'] - }) + const definitions = await manager.getAllToolDefinitions({ enabledServerIds: [] }) + const result = await manager.callTool( + { + id: 'plugin-tool', + type: 'function', + function: { + name: 'echo', + arguments: '{}' + }, + conversationId: 'deepchat-session', + providerId: 'openai' + }, + { + agentId: 'deepchat', + enabledServerIds: [] + } + ) - expect(blockedDefinitions).toEqual([]) - expect(allowedDefinitions.map((tool) => tool.server.name)).toEqual(['plugin-source-server']) + expect(definitions.map((tool) => tool.server.name)).toEqual(['plugin-source-server']) + expect(result.isError).toBe(false) + expect(pluginClient.callTool).toHaveBeenCalledWith('echo', {}) }) it('blocks DeepChat MCP tool calls outside enabled server policy', async () => { diff --git a/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts index c86fbb2a2..bc7360ab7 100644 --- a/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts @@ -1018,7 +1018,7 @@ describe('RemoteControlPresenter', () => { expect(saved.defaultAgentId).toBe('deepchat') }) - it('lists builtin remote channels including discord, qqbot, and weixin-ilink', async () => { + it('lists remote channels with Cron delivery capability', async () => { const configPresenter = createConfigPresenter() const presenter = new RemoteControlPresenter({ @@ -1029,22 +1029,24 @@ describe('RemoteControlPresenter', () => { tabPresenter: {} as any }) - await expect(presenter.listRemoteChannels()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'discord', - implemented: true - }), - expect.objectContaining({ - id: 'qqbot', - implemented: true - }), - expect.objectContaining({ - id: 'weixin-ilink', - implemented: true - }) - ]) - ) + const channels = await presenter.listRemoteChannels() + + expect(channels.map((channel) => channel.id)).toEqual([ + 'telegram', + 'feishu', + 'qqbot', + 'discord', + 'weixin-ilink' + ]) + expect( + Object.fromEntries(channels.map((channel) => [channel.id, channel.supportsCronDelivery])) + ).toEqual({ + telegram: true, + feishu: true, + qqbot: false, + discord: true, + 'weixin-ilink': true + }) }) it('saves discord remote settings without touching unrelated config', async () => { diff --git a/test/main/presenter/skillPresenter/skillTools.test.ts b/test/main/presenter/skillPresenter/skillTools.test.ts index 2a1d4964a..ffdccdcbf 100644 --- a/test/main/presenter/skillPresenter/skillTools.test.ts +++ b/test/main/presenter/skillPresenter/skillTools.test.ts @@ -161,6 +161,22 @@ describe('SkillTools', () => { expect(result.activeCount).toBe(0) expect(result.skills.map((skill) => skill.name)).toEqual(['code-review', 'git-commit']) }) + + it('keeps plugin-owned skills available through the skill policy', async () => { + ;(mockSkillPresenter.getMetadataList as Mock).mockResolvedValue([ + { + name: 'plugin-skill', + description: 'Plugin skill', + path: '/plugins/fixture/SKILL.md', + skillRoot: '/plugins/fixture', + ownerPluginId: 'com.deepchat.plugins.fixture' + } + ]) + + const result = await skillTools.handleSkillList('conv-123', ['plugin-skill']) + + expect(result.skills.map((skill) => skill.name)).toEqual(['plugin-skill']) + }) }) describe('handleSkillView', () => { diff --git a/test/main/presenter/sqlitePresenter/deepchatMessagesTable.test.ts b/test/main/presenter/sqlitePresenter/deepchatMessagesTable.test.ts index 22d5bd36d..2ace170f0 100644 --- a/test/main/presenter/sqlitePresenter/deepchatMessagesTable.test.ts +++ b/test/main/presenter/sqlitePresenter/deepchatMessagesTable.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { DeepChatMessagesTable } from '@/presenter/sqlitePresenter/tables/deepchatMessages' +import { DeepChatMessageTracesTable } from '@/presenter/sqlitePresenter/tables/deepchatMessageTraces' import { Database, nativeSqliteDescribeIf } from '../../nativeSqliteHarness' const DatabaseCtor = Database! @@ -80,7 +81,7 @@ describe('DeepChatMessagesTable', () => { }) }) -describeIfNativeSqlite('DeepChatMessagesTable existence query', () => { +describeIfNativeSqlite('DeepChatMessagesTable runtime projection', () => { function createTable() { const db = new DatabaseCtor(':memory:') const table = new DeepChatMessagesTable(db) @@ -107,15 +108,92 @@ describeIfNativeSqlite('DeepChatMessagesTable existence query', () => { } }) + it('loads ordered runtime history without the trace table', () => { + const { db, table } = createTable() + try { + table.insert({ + id: 'm2', + sessionId: 's1', + orderSeq: 2, + role: 'assistant', + content: '[]', + status: 'sent' + }) + table.insert({ + id: 'other', + sessionId: 's2', + orderSeq: 1, + role: 'user', + content: '{}', + status: 'sent' + }) + table.insert({ + id: 'm1', + sessionId: 's1', + orderSeq: 1, + role: 'user', + content: '{}', + status: 'sent' + }) + + const rows = table.getBySession('s1') + + expect(rows.map((row) => row.id)).toEqual(['m1', 'm2']) + expect(rows.every((row) => row.trace_count === undefined)).toBe(true) + expect(table.get('m1')?.trace_count).toBeUndefined() + } finally { + db.close() + } + }) + + it('keeps trace counts on the UI pagination projection', () => { + const { db, table } = createTable() + try { + const traces = new DeepChatMessageTracesTable(db) + traces.createTable() + table.insert({ + id: 'm1', + sessionId: 's1', + orderSeq: 1, + role: 'assistant', + content: '[]', + status: 'sent' + }) + for (let requestSeq = 1; requestSeq <= 2; requestSeq += 1) { + traces.insert({ + id: `t${requestSeq}`, + messageId: 'm1', + sessionId: 's1', + providerId: 'openai', + modelId: 'gpt-4o', + requestSeq, + endpoint: 'https://api.openai.test/v1/responses', + headersJson: '{}', + bodyJson: '{}', + truncated: false + }) + } + + expect(table.listPageBySession('s1', { limit: 100 })[0]?.trace_count).toBe(2) + } finally { + db.close() + } + }) + it('uses the existing session index', () => { const { db } = createTable() try { const plan = db - .prepare('EXPLAIN QUERY PLAN SELECT 1 FROM deepchat_messages WHERE session_id = ? LIMIT 1') + .prepare( + 'EXPLAIN QUERY PLAN SELECT * FROM deepchat_messages WHERE session_id = ? ORDER BY order_seq' + ) .all('s1') as Array<{ detail: string }> expect(plan.some((row) => /idx_deepchat_messages_session/i.test(row.detail))).toBe(true) expect(plan.some((row) => /\bSCAN deepchat_messages\b/i.test(row.detail))).toBe(false) + expect(plan.some((row) => /deepchat_message_traces|materialize/i.test(row.detail))).toBe( + false + ) } finally { db.close() } diff --git a/test/main/presenter/toolPresenter/toolPresenter.test.ts b/test/main/presenter/toolPresenter/toolPresenter.test.ts index 7c5988d04..c164878e0 100644 --- a/test/main/presenter/toolPresenter/toolPresenter.test.ts +++ b/test/main/presenter/toolPresenter/toolPresenter.test.ts @@ -546,7 +546,7 @@ describe('ToolPresenter', () => { expect(upsertCronJob).not.toHaveBeenCalled() }) - it('passes DeepChat agent MCP policy context to MCP presenter', async () => { + it('passes DeepChat agent MCP server policy context to MCP presenter', async () => { const mcpPresenter = { getAllToolDefinitions: vi.fn().mockResolvedValue([]), callTool: vi.fn() @@ -567,7 +567,6 @@ describe('ToolPresenter', () => { await toolPresenter.getAllToolDefinitions({ agentId: 'agent-1', enabledMcpServerIds: ['server-a'], - enabledPluginIds: ['plugin-a'], chatMode: 'agent', conversationId: 'session-1' }) @@ -576,7 +575,6 @@ describe('ToolPresenter', () => { expect.objectContaining({ agentId: 'agent-1', enabledServerIds: ['server-a'], - enabledPluginIds: ['plugin-a'], conversationId: 'session-1' }) ) @@ -605,7 +603,6 @@ describe('ToolPresenter', () => { await toolPresenter.getAllToolDefinitions({ agentId: 'agent-1', enabledMcpServerIds: undefined, - enabledPluginIds: undefined, chatMode: 'agent', conversationId: 'session-unrestricted' }) @@ -627,8 +624,7 @@ describe('ToolPresenter', () => { expect.objectContaining({ conversationId: 'session-unrestricted' }), expect.objectContaining({ agentId: 'agent-1', - enabledServerIds: undefined, - enabledPluginIds: undefined + enabledServerIds: undefined }) ) }) diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index f22ff23d3..93934a365 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -679,12 +679,9 @@ function createRuntime() { listRemoteChannels: vi.fn().mockResolvedValue([ { id: 'telegram', - type: 'builtin', - implemented: true, titleKey: 'settings.remote.telegram.title', descriptionKey: 'settings.remote.telegram.description', - supportsPairing: true, - supportsNotifications: false + supportsCronDelivery: true } ]), getChannelSettings: vi.fn().mockResolvedValue({ diff --git a/test/renderer/api/clients.test.ts b/test/renderer/api/clients.test.ts index 90e09c1ec..621880308 100644 --- a/test/renderer/api/clients.test.ts +++ b/test/renderer/api/clients.test.ts @@ -760,12 +760,9 @@ describe('renderer api clients', () => { channels: [ { id: 'telegram', - type: 'builtin', - implemented: true, titleKey: 'settings.remote.telegram.title', descriptionKey: 'settings.remote.telegram.description', - supportsPairing: true, - supportsNotifications: false + supportsCronDelivery: true } ] } diff --git a/test/renderer/components/AgentExtensionPolicyPanel.test.ts b/test/renderer/components/AgentExtensionPolicyPanel.test.ts deleted file mode 100644 index 5f46e48e5..000000000 --- a/test/renderer/components/AgentExtensionPolicyPanel.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { defineComponent } from 'vue' -import { flushPromises, shallowMount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' - -const ButtonStub = defineComponent({ - name: 'Button', - props: { - disabled: { type: Boolean, default: false } - }, - emits: ['click'], - template: - '' -}) - -const mocks = vi.hoisted(() => ({ - configClient: { - listAgents: vi.fn(), - updateDeepChatAgent: vi.fn(), - onAgentsChanged: vi.fn(), - getSetting: vi.fn() - }, - pluginClient: { - listPlugins: vi.fn() - }, - skillClient: { - getMetadataList: vi.fn() - }, - mcpClient: { - getMcpServers: vi.fn() - } -})) - -vi.mock('@api/ConfigClient', () => ({ - createConfigClient: () => mocks.configClient -})) -vi.mock('@api/PluginClient', () => ({ - createPluginClient: () => mocks.pluginClient -})) -vi.mock('@api/SkillClient', () => ({ - createSkillClient: () => mocks.skillClient -})) -vi.mock('@api/McpClient', () => ({ - createMcpClient: () => mocks.mcpClient -})) -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key: string, params?: Record) => - params?.count === undefined ? key : `${key}:${String(params.count)}` - }) -})) -vi.mock('@iconify/vue', () => ({ - Icon: defineComponent({ - name: 'Icon', - template: '' - }) -})) - -describe('AgentExtensionPolicyPanel', () => { - async function mountPanel(options: { kinds?: string[]; standalone?: boolean } = {}) { - vi.resetModules() - vi.clearAllMocks() - setActivePinia(createPinia()) - - const agent = { - id: 'deepchat', - type: 'deepchat', - name: 'DeepChat', - enabled: true, - protected: true, - config: {} - } - - mocks.configClient.listAgents.mockResolvedValue([agent]) - mocks.configClient.updateDeepChatAgent.mockResolvedValue(agent) - mocks.configClient.onAgentsChanged.mockReturnValue(() => undefined) - mocks.configClient.getSetting.mockResolvedValue(null) - mocks.pluginClient.listPlugins.mockResolvedValue([ - { - id: 'plugin-alpha', - name: 'Plugin Alpha', - version: '1.0.0', - publisher: 'DeepChat', - installed: true, - enabled: true, - trusted: true, - trustState: 'trusted', - official: true, - capabilities: [] - } - ]) - mocks.skillClient.getMetadataList.mockResolvedValue([ - { name: 'skill-alpha', description: 'Skill Alpha', path: '', skillRoot: '' } - ]) - mocks.mcpClient.getMcpServers.mockResolvedValue({ - 'server-alpha': { - command: '', - args: [], - env: {}, - descriptions: 'Server Alpha', - icons: '', - autoApprove: [], - enabled: true, - type: 'stdio' - }, - 'plugin-owned': { - command: '', - args: [], - env: {}, - descriptions: 'Plugin Owned', - icons: '', - autoApprove: [], - enabled: true, - type: 'stdio', - ownerPluginId: 'plugin-alpha' - }, - 'plugin-source-owned': { - command: '', - args: [], - env: {}, - descriptions: 'Plugin Source Owned', - icons: '', - autoApprove: [], - enabled: true, - type: 'stdio', - source: 'plugin', - sourceId: 'plugin-alpha' - } - }) - - const { useAgentStore } = await import('@/stores/ui/agent') - const { useSessionStore } = await import('@/stores/ui/session') - useAgentStore().setSelectedAgent('deepchat') - useSessionStore().activeSessionId = null - - const AgentExtensionPolicyPanel = ( - await import('@/pages/plugins/AgentExtensionPolicyPanel.vue') - ).default - const wrapper = shallowMount(AgentExtensionPolicyPanel, { - props: options, - global: { - stubs: { - Button: ButtonStub, - Icon: true - } - } - }) - await flushPromises() - - return { wrapper } - } - - it('renders a skills-only agent policy view without global skill toggles', async () => { - const { wrapper } = await mountPanel({ kinds: ['skills'], standalone: true }) - - expect(wrapper.find('[data-testid="agent-extension-plugins-mode"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="agent-extension-mcp-mode"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="agent-extension-skills-mode"]').exists()).toBe(true) - expect(wrapper.text()).not.toContain('settings.skills.title') - - await wrapper.find('[data-testid="agent-extension-skills-mode"]').trigger('click') - expect(wrapper.text()).toContain('skill-alpha') - - await wrapper.find('[data-testid="agent-extension-policy-save"]').trigger('click') - await flushPromises() - - expect(mocks.configClient.updateDeepChatAgent).toHaveBeenCalledWith('deepchat', { - config: { - enabledSkillNames: ['skill-alpha'] - } - }) - }) - - it('saves plugin skill and global MCP policy for the current agent', async () => { - const { wrapper } = await mountPanel() - - expect(wrapper.text()).not.toContain('Plugin Owned') - expect(wrapper.text()).not.toContain('Plugin Source Owned') - - for (const selector of [ - '[data-testid="agent-extension-plugins-mode"]', - '[data-testid="agent-extension-skills-mode"]', - '[data-testid="agent-extension-mcp-mode"]' - ]) { - await wrapper.find(selector).trigger('click') - } - - expect(wrapper.text()).toContain('Plugin Alpha') - expect(wrapper.text()).toContain('skill-alpha') - expect(wrapper.text()).toContain('Server Alpha') - expect(wrapper.text()).not.toContain('Plugin Owned') - expect(wrapper.text()).not.toContain('Plugin Source Owned') - - await wrapper.find('[data-testid="agent-extension-policy-save"]').trigger('click') - await flushPromises() - - expect(mocks.configClient.updateDeepChatAgent).toHaveBeenCalledWith('deepchat', { - config: { - enabledPluginIds: ['plugin-alpha'], - enabledSkillNames: ['skill-alpha'], - enabledMcpServerIds: ['server-alpha'] - } - }) - }) -}) diff --git a/test/renderer/components/OfficialPluginDetailPage.test.ts b/test/renderer/components/OfficialPluginDetailPage.test.ts index 33912fea2..8293fc53b 100644 --- a/test/renderer/components/OfficialPluginDetailPage.test.ts +++ b/test/renderer/components/OfficialPluginDetailPage.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { createPinia } from 'pinia' import { defineComponent } from 'vue' import { flushPromises, shallowMount } from '@vue/test-utils' +vi.mock('pinia', async () => vi.importActual('pinia')) + const passthrough = (name: string) => defineComponent({ name, @@ -40,6 +43,7 @@ const translations: Record = { 'settings.pluginsHub.capabilities': 'Capabilities', 'settings.pluginsHub.cuaDescription': 'CUA localized description', 'settings.plugins.runtime': 'Runtime', + 'settings.plugins.runtimeState': 'State', 'settings.plugins.version': 'Version', 'settings.remote.feishu.description': 'Feishu localized description', 'settings.remote.feishu.title': 'Feishu localized title', @@ -81,7 +85,7 @@ async function mountDetail( const pluginId = options.pluginId ?? 'com.deepchat.plugins.feishu' const remoteChannel = pluginId.startsWith('remote:') ? pluginId.slice('remote:'.length) : 'feishu' - const remoteEnabled = options.remoteEnabled ?? false + let remoteEnabled = options.remoteEnabled ?? false const pluginName = pluginId === 'com.deepchat.plugins.cua' ? 'CUA Computer Use Runtime' : 'Feishu/Lark Integration' const pluginClient = { @@ -91,29 +95,30 @@ async function mountDetail( publisher: 'DeepChat', version: '1.0.4', enabled: options.enabled ?? false, - capabilities: [], + capabilities: ['runtime.manage'], mcpServers: [] }), enablePlugin: vi.fn().mockResolvedValue({ ok: true }), disablePlugin: vi.fn().mockResolvedValue({ ok: true }) } const remoteControlClient = { - getChannelSettings: vi - .fn() - .mockResolvedValue( - remoteChannel === 'telegram' - ? defaultTelegramSettings(remoteEnabled) - : defaultFeishuSettings(remoteEnabled) - ), - getChannelStatus: vi.fn().mockResolvedValue({ + getChannelSettings: vi.fn(async () => + remoteChannel === 'telegram' + ? defaultTelegramSettings(remoteEnabled) + : defaultFeishuSettings(remoteEnabled) + ), + getChannelStatus: vi.fn(async () => ({ channel: remoteChannel, enabled: remoteEnabled, state: remoteEnabled ? 'running' : 'disabled', bindingCount: 1, allowedUserCount: 1, lastError: null - }), - saveChannelSettings: vi.fn().mockResolvedValue({}) + })), + saveChannelSettings: vi.fn(async (_channel: string, settings: { remoteEnabled: boolean }) => { + remoteEnabled = settings.remoteEnabled + return settings + }) } const router = { push: vi.fn() @@ -160,6 +165,7 @@ async function mountDetail( .default const wrapper = shallowMount(OfficialPluginDetailPage, { global: { + plugins: [createPinia()], stubs: { Button: buttonStub, ScrollArea: passthrough('ScrollArea'), @@ -216,6 +222,15 @@ describe('OfficialPluginDetailPage', () => { expect(wrapper.text()).not.toContain('DeepChat · com.deepchat.plugins.cua') }) + it('uses a distinct runtime state label without internal capabilities', async () => { + const { wrapper } = await mountDetail({ pluginId: 'com.deepchat.plugins.cua' }) + + expect(wrapper.text()).toContain('Runtime') + expect(wrapper.text()).toContain('State') + expect(wrapper.text()).not.toContain('Capabilities') + expect(wrapper.text()).not.toContain('runtime.manage') + }) + it('uses the plugin enable button to start Feishu remote too', async () => { const { wrapper, pluginClient, remoteControlClient } = await mountDetail() @@ -235,6 +250,8 @@ describe('OfficialPluginDetailPage', () => { 'feishu', expect.objectContaining({ remoteEnabled: true }) ) + expect(pluginClient.getPlugin).toHaveBeenCalledTimes(1) + expect(wrapper.findAll('button').some((button) => button.text() === 'Disable')).toBe(true) expect(findRemoteSettingsKey(wrapper)).toBe('feishu:1') }) @@ -279,6 +296,7 @@ describe('OfficialPluginDetailPage', () => { 'telegram', expect.objectContaining({ remoteEnabled: true }) ) + expect(wrapper.findAll('button').some((button) => button.text() === 'Disable')).toBe(true) }) it('uses the top detail button to stop remote virtual plugins', async () => { diff --git a/test/renderer/components/PluginPageWrappers.test.ts b/test/renderer/components/PluginPageWrappers.test.ts index 986e352fe..78f665e67 100644 --- a/test/renderer/components/PluginPageWrappers.test.ts +++ b/test/renderer/components/PluginPageWrappers.test.ts @@ -6,12 +6,10 @@ describe('plugins page wrappers', () => { it('renders the original skills settings view in agent scope', () => { expect(skillsSource).toContain('') expect(skillsSource).toContain('settings/components/skills/SkillsSettings.vue') - expect(skillsSource).not.toContain('AgentExtensionPolicyPanel') }) it('renders the original MCP settings view in global scope', () => { expect(mcpSource).toContain('') expect(mcpSource).toContain('settings/components/McpSettings.vue') - expect(mcpSource).not.toContain('AgentExtensionPolicyPanel') }) }) diff --git a/test/renderer/components/PluginsCatalogPage.test.ts b/test/renderer/components/PluginsCatalogPage.test.ts index 16f9f6a05..00adc384e 100644 --- a/test/renderer/components/PluginsCatalogPage.test.ts +++ b/test/renderer/components/PluginsCatalogPage.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { createPinia } from 'pinia' import { defineComponent } from 'vue' import { flushPromises, shallowMount } from '@vue/test-utils' +vi.mock('pinia', async () => vi.importActual('pinia')) + const passthrough = (name: string) => defineComponent({ name, @@ -61,12 +64,9 @@ async function mountCatalog() { listRemoteChannels: vi.fn().mockResolvedValue([ { id: 'feishu', - type: 'builtin', - implemented: true, titleKey: 'settings.remote.feishu.title', descriptionKey: 'settings.remote.feishu.description', - supportsPairing: true, - supportsNotifications: false + supportsCronDelivery: true } ]), getChannelStatus: vi.fn().mockResolvedValue({ @@ -109,6 +109,7 @@ async function mountCatalog() { const PluginsCatalogPage = (await import('@/pages/plugins/PluginsCatalogPage.vue')).default const wrapper = shallowMount(PluginsCatalogPage, { global: { + plugins: [createPinia()], stubs: { Button: buttonStub, ScrollArea: passthrough('ScrollArea') @@ -184,4 +185,22 @@ describe('PluginsCatalogPage', () => { expect(cards[1].text()).toContain('Add') expect(cards[1].text()).toContain('Disabled') }) + + it('keeps the current remote catalog when the IPC refresh fails', async () => { + const { wrapper, remoteControlClient } = await mountCatalog() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(wrapper.findAll('article')).toHaveLength(2) + remoteControlClient.listRemoteChannels.mockRejectedValueOnce(new Error('IPC unavailable')) + + await (wrapper.vm as any).loadCatalog() + await flushPromises() + + expect(wrapper.findAll('article')).toHaveLength(2) + expect(warn).toHaveBeenCalledWith( + '[PluginsCatalogPage] Failed to load remote channels:', + expect.objectContaining({ message: 'IPC unavailable' }) + ) + warn.mockRestore() + }) }) diff --git a/test/renderer/components/PluginsHubPage.test.ts b/test/renderer/components/PluginsHubPage.test.ts new file mode 100644 index 000000000..c4ddcbc7f --- /dev/null +++ b/test/renderer/components/PluginsHubPage.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, shallowMount } from '@vue/test-utils' + +vi.mock('pinia', async () => vi.importActual('pinia')) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'plugins' }), + RouterLink: { + name: 'RouterLink', + template: '' + }, + RouterView: { + name: 'RouterView', + template: '
' + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => + ({ + 'routes.plugins': 'Plugins', + 'routes.settings-skills': 'Skills', + 'routes.settings-mcp': 'MCP', + 'settings.pluginsHub.acpUnavailableTitle': 'Plugins are unavailable', + 'settings.pluginsHub.acpUnavailableDescription': + 'ACP agents manage extensions through their own runtime.' + })[key] ?? key + }) +})) + +vi.mock('@iconify/vue', () => ({ + Icon: { + name: 'Icon', + template: '' + } +})) + +vi.mock('@api/ConfigClient', () => ({ + createConfigClient: () => ({ + onAgentsChanged: () => () => undefined + }) +})) + +vi.mock('@api/SessionClient', () => ({ + createSessionClient: () => ({}) +})) + +describe('PluginsHubPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('replaces the hub with an unavailable state for ACP agents', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const { useAgentStore } = await import('@/stores/ui/agent') + const agentStore = useAgentStore() + agentStore.applyBootstrapAgents([ + { + id: 'deepchat', + name: 'DeepChat', + type: 'deepchat', + enabled: true + }, + { + id: 'codex', + name: 'Codex', + type: 'acp', + enabled: true + } + ]) + agentStore.setSelectedAgent('codex') + + const PluginsHubPage = (await import('@/pages/plugins/PluginsHubPage.vue')).default + const wrapper = shallowMount(PluginsHubPage, { + global: { + plugins: [pinia] + } + }) + + expect(wrapper.find('[data-testid="plugins-acp-unavailable"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Plugins are unavailable') + expect(wrapper.find('nav').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'RouterView' }).exists()).toBe(false) + + agentStore.setSelectedAgent('deepchat') + await flushPromises() + + expect(wrapper.find('[data-testid="plugins-acp-unavailable"]').exists()).toBe(false) + expect(wrapper.find('nav').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'RouterView' }).exists()).toBe(true) + }) +}) diff --git a/test/renderer/components/RemoteSettings.test.ts b/test/renderer/components/RemoteSettings.test.ts index 9533d29c6..3a32f8157 100644 --- a/test/renderer/components/RemoteSettings.test.ts +++ b/test/renderer/components/RemoteSettings.test.ts @@ -288,11 +288,36 @@ const setup = async (options: SetupOptions = {}) => { const remoteControlPresenter = { listRemoteChannels: vi.fn(async () => [ - { id: 'telegram', implemented: true }, - { id: 'feishu', implemented: true }, - { id: 'qqbot', implemented: true }, - { id: 'discord', implemented: true }, - { id: 'weixin-ilink', implemented: true } + { + id: 'telegram' as const, + titleKey: 'settings.remote.telegram.title', + descriptionKey: 'settings.remote.telegram.description', + supportsCronDelivery: true + }, + { + id: 'feishu' as const, + titleKey: 'settings.remote.feishu.title', + descriptionKey: 'settings.remote.feishu.description', + supportsCronDelivery: true + }, + { + id: 'qqbot' as const, + titleKey: 'settings.remote.qqbot.title', + descriptionKey: 'settings.remote.qqbot.description', + supportsCronDelivery: false + }, + { + id: 'discord' as const, + titleKey: 'settings.remote.discord.title', + descriptionKey: 'settings.remote.discord.description', + supportsCronDelivery: true + }, + { + id: 'weixin-ilink' as const, + titleKey: 'settings.remote.weixinIlink.title', + descriptionKey: 'settings.remote.weixinIlink.description', + supportsCronDelivery: true + } ]), getChannelSettings: vi.fn( async (channel: 'telegram' | 'feishu' | 'qqbot' | 'discord' | 'weixin-ilink') => { diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index 56bc1988b..30d0352ca 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPinia } from 'pinia' import { defineComponent, reactive, ref } from 'vue' import { flushPromises, mount } from '@vue/test-utils' +vi.mock('pinia', async () => vi.importActual('pinia')) + type SetupOptions = { groupMode?: 'time' | 'project' selectedAgentId?: string | null @@ -270,11 +273,36 @@ const setup = async (options: SetupOptions = {}) => { } const remoteControlClient = { listRemoteChannels: vi.fn(async () => [ - { id: 'telegram', implemented: true }, - { id: 'feishu', implemented: true }, - { id: 'qqbot', implemented: true }, - { id: 'discord', implemented: true }, - { id: 'weixin-ilink', implemented: true } + { + id: 'telegram' as const, + titleKey: 'settings.remote.telegram.title', + descriptionKey: 'settings.remote.telegram.description', + supportsCronDelivery: true + }, + { + id: 'feishu' as const, + titleKey: 'settings.remote.feishu.title', + descriptionKey: 'settings.remote.feishu.description', + supportsCronDelivery: true + }, + { + id: 'qqbot' as const, + titleKey: 'settings.remote.qqbot.title', + descriptionKey: 'settings.remote.qqbot.description', + supportsCronDelivery: false + }, + { + id: 'discord' as const, + titleKey: 'settings.remote.discord.title', + descriptionKey: 'settings.remote.discord.description', + supportsCronDelivery: true + }, + { + id: 'weixin-ilink' as const, + titleKey: 'settings.remote.weixinIlink.title', + descriptionKey: 'settings.remote.weixinIlink.description', + supportsCronDelivery: true + } ]), getChannelStatus: vi.fn( async (channel: 'telegram' | 'feishu' | 'qqbot' | 'discord' | 'weixin-ilink') => @@ -459,6 +487,7 @@ const setup = async (options: SetupOptions = {}) => { const wrapper = trackMountedWrapper( mount(WindowSideBar, { global: { + plugins: [createPinia()], stubs: { TooltipProvider: passthrough, Tooltip: passthrough, @@ -1835,6 +1864,41 @@ describe('WindowSideBar agent switch', () => { disabledSetup.wrapper.unmount() }) + it('keeps the previous remote display when a refresh fails', async () => { + const { wrapper, remoteControlClient, router } = await setup({ + remoteStatus: { + enabled: true, + state: 'running' + } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + remoteControlClient.listRemoteChannels.mockResolvedValueOnce([ + { + id: 'qqbot', + titleKey: 'settings.remote.qqbot.title', + descriptionKey: 'settings.remote.qqbot.description', + supportsCronDelivery: false + } + ]) + remoteControlClient.getChannelStatus.mockRejectedValueOnce(new Error('IPC unavailable')) + + await expect((wrapper.vm as any).refreshRemoteControlStatus()).resolves.toBe(false) + await wrapper.find('[data-testid="remote-control-button"]').trigger('click') + + expect(router.push).toHaveBeenLastCalledWith({ + name: 'plugins-detail', + params: { pluginId: 'remote:telegram' } + }) + expect(warn).toHaveBeenCalledWith( + '[WindowSideBar] Failed to refresh remote control status:', + expect.any(Error) + ) + + warn.mockRestore() + wrapper.unmount() + }) + it('routes to the first enabled remote plugin when remote button is clicked', async () => { const { wrapper, settingsClient, router } = await setup({ remoteStatus: { diff --git a/test/renderer/components/trace/TraceDialog.test.ts b/test/renderer/components/trace/TraceDialog.test.ts index 4f44e1dba..2b8de45ef 100644 --- a/test/renderer/components/trace/TraceDialog.test.ts +++ b/test/renderer/components/trace/TraceDialog.test.ts @@ -107,6 +107,34 @@ vi.mock( { virtual: true } ) +vi.mock( + '@shadcn/components/ui/select', + () => ({ + Select: { + name: 'Select', + props: ['modelValue'], + emits: ['update:modelValue'], + template: '
' + }, + SelectTrigger: { + name: 'SelectTrigger', + template: '' + }, + SelectValue: { + name: 'SelectValue', + props: ['placeholder'], + template: '{{ placeholder }}' + }, + SelectContent: { name: 'SelectContent', template: '
' }, + SelectItem: { + name: 'SelectItem', + props: ['value'], + template: '' + } + }), + { virtual: true } +) + vi.mock( '@shadcn/components/ui/tabs', () => ({ @@ -366,7 +394,7 @@ describe('TraceDialog', () => { expect(setThemeMock).toHaveBeenCalledWith('vitesse-light') }) - it('shows latest trace by default and supports switching trace history', async () => { + it('shows latest trace by default and switches history from the compact selector', async () => { listMessageTraceDiagnosticsMock.mockResolvedValue({ traces: [ { @@ -396,7 +424,7 @@ describe('TraceDialog', () => { createdAt: 1000 } ], - manifests: [] + manifests: [makeManifestRecord(2, 'view_latest', { integrity: 'valid' })] }) const wrapper = mountDialog() @@ -407,10 +435,21 @@ describe('TraceDialog', () => { expect(listMessageTraceDiagnosticsMock).toHaveBeenCalledWith('m1') expect(wrapper.text()).toContain('https://api.example.com/second') - const historyButton = wrapper.findAll('button').find((btn) => btn.text().trim() === '#1') - expect(historyButton).toBeDefined() - - await historyButton!.trigger('click') + const requestSelect = wrapper.getComponent({ name: 'Select' }) + expect(requestSelect.props('modelValue')).toBe(2) + expect(wrapper.getComponent({ name: 'SelectTrigger' }).attributes('aria-label')).toBe( + 'traceDialog.requestSeq' + ) + expect(wrapper.findAllComponents({ name: 'SelectItem' })).toHaveLength(2) + const summaryRow = requestSelect.element.parentElement + expect(summaryRow?.classList.contains('flex-wrap')).toBe(true) + expect(summaryRow?.textContent).toContain('openai') + expect(summaryRow?.textContent).toContain('gpt-4o') + const detailsRow = summaryRow?.nextElementSibling + expect(detailsRow?.textContent).toContain('traceDialog.integrity.valid') + expect(detailsRow?.textContent).toContain('https://api.example.com/second') + + requestSelect.vm.$emit('update:modelValue', 1) await flushPromises() expect(wrapper.text()).toContain('https://api.example.com/first') @@ -461,10 +500,7 @@ describe('TraceDialog', () => { expect(wrapper.text()).toContain('https://api.example.com/second') - const manifestOnlyButton = wrapper.findAll('button').find((btn) => btn.text().trim() === '#1') - expect(manifestOnlyButton).toBeDefined() - - await manifestOnlyButton!.trigger('click') + wrapper.getComponent({ name: 'Select' }).vm.$emit('update:modelValue', 1) await flushPromises() expect(wrapper.text()).toContain('traceDialog.requestUnavailable') diff --git a/test/renderer/stores/pluginCatalogStore.test.ts b/test/renderer/stores/pluginCatalogStore.test.ts new file mode 100644 index 000000000..8aa6454f6 --- /dev/null +++ b/test/renderer/stores/pluginCatalogStore.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import type { RemoteChannelDescriptor, TelegramRemoteStatus } from '@shared/presenter' +import type { PluginListItem } from '@shared/types/plugin' +import { usePluginCatalogStore } from '@/stores/pluginCatalog' + +vi.mock('pinia', async () => vi.importActual('pinia')) + +const plugin = (enabled = false): PluginListItem => ({ + id: 'com.deepchat.plugins.test', + name: 'Test plugin', + version: '1.0.0', + publisher: 'DeepChat', + installed: true, + enabled, + trusted: true, + trustState: 'trusted', + official: true, + capabilities: [] +}) + +const telegramDescriptor: RemoteChannelDescriptor = { + id: 'telegram', + titleKey: 'settings.remote.telegram.title', + descriptionKey: 'settings.remote.telegram.description', + supportsCronDelivery: true +} + +const telegramStatus = (enabled = false): TelegramRemoteStatus => ({ + channel: 'telegram', + enabled, + state: enabled ? 'running' : 'disabled', + pollOffset: 0, + bindingCount: 0, + allowedUserCount: 0, + lastError: null, + botUser: null +}) + +describe('pluginCatalogStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('shares successful plugin and remote snapshots across consumers', () => { + const store = usePluginCatalogStore() + + store.replacePlugins([plugin()], store.capturePluginRefresh()) + store.replaceRemoteSnapshot( + [telegramDescriptor], + [telegramStatus()], + store.captureRemoteRefresh() + ) + + const secondConsumer = usePluginCatalogStore() + expect(secondConsumer.getPlugin('com.deepchat.plugins.test')?.enabled).toBe(false) + expect(secondConsumer.remoteChannels).toEqual([telegramDescriptor]) + expect(secondConsumer.remoteStatuses.telegram?.state).toBe('disabled') + }) + + it('keeps an optimistic plugin update when an older refresh completes', () => { + const store = usePluginCatalogStore() + store.replacePlugins([plugin()], store.capturePluginRefresh()) + const staleRefresh = store.capturePluginRefresh() + + const previous = store.beginPluginEnabledMutation('com.deepchat.plugins.test', true) + + expect(store.getPlugin('com.deepchat.plugins.test')?.enabled).toBe(true) + expect(store.replacePlugins([plugin()], staleRefresh)).toBe(false) + expect(store.getPlugin('com.deepchat.plugins.test')?.enabled).toBe(true) + + store.rollbackPluginMutation(previous) + expect(store.getPlugin('com.deepchat.plugins.test')?.enabled).toBe(false) + }) + + it('keeps an optimistic remote update when a refresh spans the mutation', () => { + const store = usePluginCatalogStore() + store.replaceRemoteSnapshot( + [telegramDescriptor], + [telegramStatus()], + store.captureRemoteRefresh() + ) + const staleRefresh = store.captureRemoteRefresh() + + store.beginRemoteEnabledMutation('telegram', true) + const refreshDuringMutation = store.captureRemoteRefresh() + + expect(store.remoteStatuses.telegram).toMatchObject({ enabled: true, state: 'starting' }) + expect( + store.replaceRemoteSnapshot([telegramDescriptor], [telegramStatus()], staleRefresh) + ).toBe(false) + + store.commitRemoteMutation(telegramStatus(true)) + + expect( + store.replaceRemoteSnapshot([telegramDescriptor], [telegramStatus()], refreshDuringMutation) + ).toBe(false) + expect(store.remoteStatuses.telegram).toMatchObject({ enabled: true, state: 'running' }) + }) +})