diff --git a/.project-docs/30-worklog/tasks/20260903-plugin-navigation-implementation-a4c7e291.md b/.project-docs/30-worklog/tasks/20260903-plugin-navigation-implementation-a4c7e291.md
new file mode 100644
index 0000000..8418e22
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260903-plugin-navigation-implementation-a4c7e291.md
@@ -0,0 +1,162 @@
+# Task: Implement unified plugin workspace ML-PLUGIN-NAV-001
+
+## Identity
+
+- Task ID: 20260903-plugin-navigation-implementation-a4c7e291
+- Mode: Feature
+- Branch: codex/20260903-plugin-navigation-implementation-a4c7e291-plugin-navigation-implementation
+- Worktree: C:\Users\7brot\.codex\worktrees\1721\makelore
+- Base commit: 959e2faf03dfacb3c8e2035e44774436c752d4b0
+- Owner: codex
+- Status: Ready for Integration
+
+## Scope
+
+- Implement approved Spec `ML-PLUGIN-NAV-001` serially as PN-01 through PN-04:
+ a pure source-qualified Renderer projection, one `/plugins` workspace and detail
+ surface, a hard route/sidebar cutover with exact replace redirects, removal of the
+ three superseded page implementations, and focused/full/Electron verification.
+- Own only the Spec-listed Renderer page/navigation/tests/artifact-marker/README
+ paths plus this task record. Preserve the existing Marketplace, Device Package,
+ Coding Plugin, Agent assignment, runtime admission, and billing authorities.
+
+## Intent And Constraints
+
+- Concurrent Task Gate passed for the exact identity/base/worktree above. The
+ superseded exact-overlap task `20260903-plugin-navigation-implementation-6b4d9e21`
+ was released from the registry after source-thread adjudication; its worktree,
+ branch, and record were not removed or modified by this task.
+- Planning Gate passed after reading repository guidance, the complete Spec, startup
+ project memory, architecture/domain rules, relevant indexes, and the completed AI
+ Design peer record. That peer explicitly excludes plugin pages, navigation,
+ Sidebar, and router, so no semantic overlap remains.
+- Confirmed public test seams are `buildPluginWorkspaceProjection`, the unified
+ Renderer page/local dispatcher/detail dialog, route/sidebar/module guards, and the
+ targeted Renderer-to-Main Electron flows. Work vertically red -> green at those
+ seams rather than mocking internal modules.
+- Use `official:`, `local:`, and `retained:` identity without inferred cross-source
+ merging. Keep local installation conversation-only, native selected-model Web
+ Search out of the projection, system-included actions restricted, and local package
+ state explicitly device-global.
+- Do not change Server, Electron Main/plugin contracts, the three existing stores,
+ Package Store/runtime/billing authority, app identity/global paths, or add a
+ persistent Zustand authority, compatibility layer, feature flag, or visible local
+ installation picker.
+- Do not write or clean `D:\\Datas\\OthersProjects\\makelore`; do not push, create a
+ PR, deploy, publish, install a package, or merge `main`.
+
+## Project Context Loaded
+
+- Project goal: present official, acquired, local-device, project, and Agent Plugin
+ state through one understandable UI while preserving each existing authority.
+- Current integrated focus: curated Marketplace Release A, system-included Data
+ Service, code-owned bundled Game Resource, conversation-only Device Packages, and
+ selected-model native Web Search are already integrated contracts.
+- Decisions affecting this task: acquisition/device delivery/project enablement/Agent
+ assignment/runtime admission/billing remain independent; Device Packages are
+ device-global and child workers remain empty; Renderer access stays behind typed
+ Host API facades.
+- Relevant evidence: the integrated Marketplace and Device Package suites establish
+ the current DTO/authority behavior; installed-client Device Package activation is a
+ separate release gate and is not claimed by this UI task.
+- Likely modules: `src/pages/{PluginMarketplace,MyPlugins,ProjectPlugins,Plugins}`,
+ `src/App.tsx`, layout navigation/guards, `src/lib/ai-modules.ts`, their unit/E2E and
+ artifact marker tests, and `README.md`.
+- Unknown to verify before implementation: whether the current public Renderer DTOs
+ express every Spec field without contract changes. A real interface conflict blocks
+ that range rather than authorizing a wrapper or compatibility path.
+- Non-blocking stale context: canonical `module-map.md` still names the old three-page
+ UI and must be proposed for later Integration Gate promotion, not edited in feature
+ mode.
+
+## Outcome
+
+- `PN-01` added the pure `buildPluginWorkspaceProjection` interface, canonical
+ query parser/serializer, and a local command dispatcher. The projection keeps
+ `official:`, `local:`, and `retained:` identities separate; preserves
+ catalog/Library/project-only/local/retained entries under partial failures;
+ excludes only the native selected-model `makelore_web_search`; and derives the
+ Spec action matrix without writing a store.
+- `PN-02` replaced the three page-specific views with one `/plugins` workspace and
+ one detail dialog. It independently loads Catalog, Library, Device Packages, and
+ current-project projection; exposes URL-backed scope/source/state/search/detail
+ state; keeps source errors local; dispatches every action to its existing owner;
+ embeds the existing Data Service settings surface; resets project-scoped
+ confirmation on project change; and provides dialog focus, Escape, focus-return,
+ status, alert, and destructive-scope semantics.
+- `PN-03` reduced the Code sidebar to `sidebar-nav-plugins`, registered the
+ initialization-safe and non-provider-gated canonical route, added the three exact
+ replace redirects, and deleted the old Marketplace, My Plugins, and Project
+ Plugins implementations and their superseded page tests. No hidden fallback
+ implementation remains.
+- `PN-04` updated the targeted Electron flows, product artifact markers, and
+ `README.md`. The E2E authenticated fixture now starts a fresh Renderer document
+ against a Main-owned session and `/api/auth/me` identity; this removed a measured
+ reload/localStorage setup race without changing product authentication code.
+- Actual changed scope is limited to `src/pages/Plugins/**`, the listed route/layout
+ files, deletion of the three old page directories, focused unit/Electron tests,
+ `scripts/lib/pi-product-artifact.mjs`, `README.md`, and this task record. No
+ Server, Electron Main, Marketplace/Device/Coding store, Package Store, runtime,
+ billing, or persistence authority was changed; no fourth Zustand authority was
+ added.
+
+## Verification
+
+- `corepack pnpm run typecheck` — passed after the final E2E fixture edit.
+- `corepack pnpm run lint:check` — passed with 0 errors. Five pre-existing warnings
+ remain in untouched files: one `react-hooks/exhaustive-deps` warning in
+ `src/pages/Home/index.tsx` and four `react-refresh/only-export-components`
+ warnings in `src/pages/Makelore/index.tsx`.
+- `corepack pnpm test` — passed: 225 regular test files with 1821 passed and 2
+ skipped tests, followed by the isolated pressure file with 1 passed test.
+- `corepack pnpm run build:vite` — passed for Renderer, Electron Main, Preload, and
+ utility worker. Existing warnings remain for stale Browserslist data,
+ static/dynamic imports, and the existing large Renderer chunk.
+- `corepack pnpm run test:e2e -- tests/e2e/plugin-marketplace.spec.ts
+ tests/e2e/project-plugins.spec.ts --reporter=line` — passed after its production
+ build with all 4 targeted Renderer-to-Main flows.
+- `corepack pnpm exec playwright test tests/e2e/plugin-marketplace.spec.ts -g
+ "keeps system, bundled, and local actions" --repeat-each=10 --workers=1
+ --reporter=line` — 10/10 passed after stabilizing the authenticated fixture.
+- `corepack pnpm exec vitest run tests/unit/pi-product-artifact.test.ts
+ --maxWorkers=1` — passed: 15/15 artifact proof tests.
+- Focused projection/query/controller/page/navigation/Main-layout suites passed;
+ the final page suite passed 5/5, including project-switch confirmation reset and
+ dialog initial-focus/Escape/focus-return behavior.
+- Existing parent/child/worker lifecycle coverage passed in the full suite through
+ `coding-plugin-effective-resolver`, `pi-managed-worker-opener`,
+ `device-package-manager`, and `device-package-routes` tests.
+- `check_doc_drift.py --task-id
+ 20260903-plugin-navigation-implementation-a4c7e291` — passed the feature-task
+ ownership boundary check.
+- `git diff --check` passed before Task Documentation Gate finalization.
+- One full-suite run transiently failed the untouched Marketplace client temp
+ Package Store install case; its exact isolated rerun passed 1/1 and the subsequent
+ full suite passed as recorded above. An earlier targeted E2E run exposed the
+ test-only auth reload race; trace inspection showed no Library request and a
+ signed-out persisted fixture, after which the clean-document Main-session fixture
+ passed 10/10 and the formal targeted command passed 4/4.
+
+## Follow-ups
+
+- No implementation follow-up or product deviation is known. The canonical module
+ map promotion below remains intentionally deferred to an Integration Gate.
+
+## Promotion Candidates
+
+- Target: `.project-docs/20-architecture/module-map.md`.
+- Proposal: replace the three legacy Renderer plugin pages with the pure unified
+ projection, `/plugins` workspace/detail surface, canonical query state, and exact
+ legacy redirects; keep Catalog, Library, official Package Store, Device Packages,
+ project enablement, Agent assignment, admission, and billing under their existing
+ owners.
+- Evidence: approved Spec `ML-PLUGIN-NAV-001`, this task's single implementation
+ commit, pure projection/action tests, route/page tests, artifact proof, full unit
+ suite, production build, and 4/4 targeted Electron flows.
+- Future impact: architecture readers will use `/plugins` and
+ `src/pages/Plugins/plugin-workspace-model.ts` as the Renderer aggregation seam and
+ will not extend the deleted page implementations.
+- Semantic conflicts: none known; the concurrent AI Design task owns no plugin
+ navigation or plugin authority surface.
+- Human confirmation: no new product-direction decision is required, but promotion
+ must still occur only in an exclusively owned Integration Gate.
diff --git a/README.md b/README.md
index a72598e..0c8cf62 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人
- AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。
- AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。
- AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。
-- 插件市场 Release A:插件中心(Marketplace)提供运营精选的免费 `skill_only` 插件;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是 Project Plugins 中彼此独立的动作。我的插件(My Plugins)展示账号获取状态、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason;Project Plugins 继续只修改项目选择,不会因获取或下载自动启用或分配。
+- 插件工作台:Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。
- 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。
- 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。
- 本机 Device Packages 只由 Conversation 工具发起安装,支持 npm、Git、绝对本地 Plugin 目录和 loose `SKILL.md`。Main 在预览和独立确认后提交不可变本机 generation,禁用生命周期脚本;新建/空闲 parent 会同时加载所有显式安装且当前启用的 Skill,以及必需 Makelore 扩展之外的全部 Pi extension,active parent 在 turn settled 后切换,child 不继承这些资源,也不会扫描 ambient 项目或用户目录。
diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs
index ba85fab..021e2fd 100644
--- a/scripts/lib/pi-product-artifact.mjs
+++ b/scripts/lib/pi-product-artifact.mjs
@@ -68,7 +68,8 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({
'/api/coding/plugin-marketplace/catalog',
'/api/coding/plugin-marketplace/library',
'免费获取',
- '我的插件',
+ '获取、项目启用与伙伴分配彼此独立',
+ '/plugins',
]),
});
const FORBIDDEN_PROVIDER_AUTHORITY_MARKERS = Object.freeze([
diff --git a/src/App.tsx b/src/App.tsx
index b991028..c97a8d8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,15 +26,14 @@ import { subscribeHostEvent } from '@/lib/host-events';
import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api';
import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics';
import { resolveSupportedLanguage } from '../shared/language';
+import { LegacyPluginRedirect } from './pages/Plugins/legacy-plugin-redirect';
const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component })));
const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ default: component })));
const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component })));
const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component })));
const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component })));
-const ProjectPlugins = lazy(() => import('./pages/ProjectPlugins').then(({ ProjectPlugins: component }) => ({ default: component })));
-const PluginMarketplace = lazy(() => import('./pages/PluginMarketplace').then(({ PluginMarketplace: component }) => ({ default: component })));
-const MyPlugins = lazy(() => import('./pages/MyPlugins').then(({ MyPlugins: component }) => ({ default: component })));
+const Plugins = lazy(() => import('./pages/Plugins').then(({ Plugins: component }) => ({ default: component })));
const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component })));
const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component })));
const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component })));
@@ -446,9 +445,10 @@ function App() {
)}
>
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx
index b87954d..06c2037 100644
--- a/src/components/layout/MainLayout.tsx
+++ b/src/components/layout/MainLayout.tsx
@@ -38,9 +38,7 @@ export function MainLayout() {
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isChatWorkspace = location.pathname === '/chat';
const isInitializationSafeRoute = location.pathname === '/project-config'
- || location.pathname === '/project-plugins'
- || location.pathname === '/plugin-marketplace'
- || location.pathname === '/my-plugins'
+ || location.pathname === '/plugins'
|| !isProgrammingModule;
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index f7502cc..b0e8f44 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -13,8 +13,6 @@ import {
LogOut,
Plus,
Puzzle,
- Library,
- Store,
Settings as SettingsIcon,
UserCircle2,
} from 'lucide-react';
@@ -591,33 +589,18 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
-
@@ -688,23 +671,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
) : null}
- {activeProject ? (
-
- ) : null}
>
) : isLearningModule ? (
diff --git a/src/lib/ai-modules.ts b/src/lib/ai-modules.ts
index 7b9a468..c3c2faa 100644
--- a/src/lib/ai-modules.ts
+++ b/src/lib/ai-modules.ts
@@ -65,6 +65,7 @@ const moduleAccessKeyById: Record = {
};
const PROGRAMMING_ROUTE_PREFIXES = [
+ '/plugins',
'/plugin-marketplace',
'/my-plugins',
'/project-config',
@@ -80,7 +81,6 @@ const PROGRAMMING_ROUTE_PREFIXES = [
const PROGRAMMING_PROVIDER_ROUTE_PREFIXES = [
'/project-config',
- '/project-plugins',
'/makelore-home',
'/kangaroo',
'/subagents',
diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx
deleted file mode 100644
index 0c66ea3..0000000
--- a/src/pages/MyPlugins/index.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-import { useEffect } from 'react';
-import { Link } from 'react-router-dom';
-import { AlertCircle, Download, PackageCheck, RefreshCw, Trash2 } from 'lucide-react';
-import { toast } from 'sonner';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
-import type { DevicePackageIndexV1 } from '@/lib/device-packages';
-import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
-import { devicePackageStore, useDevicePackageStore } from '@/stores/device-packages';
-import { useAuthStore } from '@/stores/auth';
-import { isCodeOwnedOptionalBundledPluginId } from '../../../shared/coding-plugins';
-
-function failureText(reason?: string): string | null {
- if (!reason) return null;
- const messages: string[] = [];
- if (reason.includes('signature')) messages.push('签名校验失败,旧版本仍保留。');
- if (reason.includes('artifact_invalid')) messages.push('插件包校验失败,旧版本仍保留。');
- if (reason.includes('yanked')) messages.push('此版本已撤回,旧版本仍保留。');
- if (reason.includes('not_ready')) messages.push('此版本尚未就绪,旧版本仍保留。');
- if (reason.includes('incompatible')) messages.push('当前 MakeLore 版本不兼容,旧版本仍保留。');
- if (reason.includes('runtime_suspended')) messages.push('插件运行已暂停,设备版本未替换。');
- if (reason.includes('backend_unavailable')) messages.push('Marketplace 服务暂不可用,设备版本未替换。');
- if (reason.includes('library_required')) messages.push('请先在账号插件库中获取此插件。');
- if (messages.length === 0) messages.push('当前版本暂不可用,旧版本仍保留。');
- return messages.join(' ');
-}
-
-function actionErrorText(error?: string | null): string | null {
- if (!error) return null;
- if (error.includes('plugin_artifact_invalid')) return '插件签名或包校验失败,设备上此前可用版本仍保留。';
- if (error.includes('plugin_signature_invalid')) return '插件签名校验失败,设备上此前可用版本仍保留。';
- if (error.includes('plugin_release_yanked')) return '此版本已撤回;不会替换此前可用版本。';
- if (error.includes('plugin_release_not_ready')) return '版本尚未就绪;不会替换此前可用版本。';
- if (error.includes('plugin_incompatible_client')) return '当前 MakeLore 版本不兼容;不会替换此前可用版本。';
- if (error.includes('plugin_runtime_suspended')) return '插件运行已暂停;不会替换此前可用版本。';
- if (error.includes('plugin_backend_unavailable')) return 'Marketplace 服务暂不可用;不会替换此前可用版本。';
- if (error.includes('plugin_library_required')) return '请先在账号插件库中获取此插件。';
- return error;
-}
-
-export type MyPluginsViewProps = {
- library: MarketplaceLibrarySnapshot | null;
- installations: Record;
- state: 'idle' | 'loading' | 'ready' | 'error';
- error?: string | null;
- pending: Record;
- onRefresh(): void | Promise;
- onInstall(pluginId: string): void | Promise;
- onInstallBeta(pluginId: string): void | Promise;
- onUpdate(pluginId: string): void | Promise;
- onUninstall(pluginId: string): void | Promise;
- onRemove(pluginId: string): void | Promise;
- onReacquire(pluginId: string): void | Promise;
- deviceIndex?: DevicePackageIndexV1 | null;
- deviceState?: 'idle' | 'loading' | 'ready' | 'error';
- deviceError?: string | null;
- devicePending?: Record;
- onDeviceRefresh?(): void | Promise;
- onDeviceSetEnabled?(packageId: string, enabled: boolean): void | Promise;
- onDeviceUninstall?(packageId: string): void | Promise;
-};
-
-export function MyPluginsView(props: MyPluginsViewProps) {
- const visibleError = actionErrorText(props.error);
- const devicePackages = props.deviceIndex?.packages ?? [];
- const devicePending = props.devicePending ?? {};
- return (
-
-
- 官方插件
- {props.library?.stale ? 正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。
: null}
- {props.state === 'loading' && !props.library ? 正在读取我的插件…
: null}
- {props.state === 'error' && !props.library ? 无法读取我的插件
{props.error ?? '请稍后重试。'}
: null}
- {visibleError && props.library ? {visibleError}
: null}
- {props.library && props.library.items.length === 0 ? 账号插件库还是空的
前往插件中心免费获取需要的插件。
: null}
-
- {props.library?.items.map((plugin) => {
- const installed = props.installations[plugin.pluginId];
- const systemIncluded = plugin.acquisition === 'system_included';
- const bundledDelivery = systemIncluded || isCodeOwnedOptionalBundledPluginId(plugin.pluginId);
- const hasDevicePackage = !bundledDelivery && Boolean(installed?.version);
- const removed = plugin.removedAt !== null;
- const suspended = plugin.runtimeStatus === 'suspended';
- const retired = plugin.catalogStatus === 'retired';
- const installedChannel = hasDevicePackage ? installed?.channel ?? 'stable' : 'stable';
- const channelVersion = installedChannel === 'beta' ? plugin.betaVersion : plugin.stableVersion;
- const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion);
- const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion);
- const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
- const failure = bundledDelivery ? null : failureText(installed?.reason);
- return
- {plugin.title}
{systemIncluded ? 系统内置 : removed ? 已移除 : 已获取}{plugin.summary}
稳定版 {plugin.stableVersion ?? '不可用'}
{plugin.betaVersion ?
Beta 版 {plugin.betaVersion}
: null}{bundledDelivery ?
随 MakeLore 提供
: hasDevicePackage ? <>
设备版本 {installed?.version}
当前频道:{installedChannel === 'beta' ? 'Beta' : '稳定'}
> :
尚未下载到设备
}
- {suspended ?
运行已暂停
: null}{retired ?
{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}
: null}{betaChannelUnavailable ?
当前 Beta 频道暂无可用版本;不会静默切回稳定版。
: null}{failure ?
{failure}
: null}
-
- {systemIncluded ? null
- : removed ?
- : bundledDelivery ? null
- : !hasDevicePackage ?
- : updateAvailable ?
- :
设备版本已是最新}
- {!removed && !bundledDelivery && plugin.betaVersion && installedChannel !== 'beta' ?
: null}
- {!removed && plugin.acquisition !== 'system_included' ?
: null}
- {hasDevicePackage ?
: null}
-
-
-
- ;
- })}
-
-
-
-
本机安装
-
- 在对话中告诉 MakeLore 要安装的 npm 包、Git 仓库 Skill 或本机绝对路径;确认预览后才会写入这台设备。
-
-
- {props.deviceState === 'loading' && !props.deviceIndex
- ? 正在读取本机插件…
- : null}
- {props.deviceState === 'error'
- ? {props.deviceError ?? '无法读取本机插件。'}
- : null}
- {devicePackages.length === 0 && props.deviceState !== 'loading'
- ? 这台设备还没有通过对话安装的包。
- : null}
-
- {devicePackages.map((item) => {
- const busy = Object.keys(devicePending).some((key) => key.endsWith(`:${item.packageId}`));
- const kind = item.kind === 'skill-only' ? 'Skill'
- : item.kind === 'pi-extension' ? 'Pi extension' : 'Skill + Pi extension';
- return
-
-
-
-
{item.displayName}
- {item.enabled ? '已启用' : '已停用'}
-
-
{kind} · {item.source.kind.toUpperCase()} · {item.source.requested}
-
-
版本 {item.resolvedVersion}
-
- {item.confirmedExecutableCode
- ? 包含可执行 Pi extension,运行时拥有完整桌面权限。
- : null}
-
-
-
-
- ;
- })}
-
-
-
- );
-}
-
-export function MyPlugins() {
- const user = useAuthStore((state) => state.user);
- const accountKey = user?.userId ?? user?.username ?? null;
- const library = usePluginMarketplaceStore((state) => state.library);
- const installations = usePluginMarketplaceStore((state) => state.installations);
- const state = usePluginMarketplaceStore((value) => value.libraryState);
- const error = usePluginMarketplaceStore((value) => value.libraryError);
- const pending = usePluginMarketplaceStore((value) => value.pending);
- const deviceIndex = useDevicePackageStore((value) => value.index);
- const deviceState = useDevicePackageStore((value) => value.state);
- const deviceError = useDevicePackageStore((value) => value.error);
- const devicePending = useDevicePackageStore((value) => value.pending);
- useEffect(() => {
- pluginMarketplaceStore.getState().activateAccount(accountKey);
- if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
- }, [accountKey]);
- useEffect(() => {
- void devicePackageStore.getState().load().catch(() => undefined);
- }, []);
- const safe = (operation: Promise) => operation.catch((reason) => {
- toast.error(reason instanceof Error ? reason.message : String(reason));
- });
- const store = pluginMarketplaceStore.getState;
- const deviceStore = devicePackageStore.getState;
- return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} deviceIndex={deviceIndex} deviceState={deviceState} deviceError={deviceError} devicePending={devicePending} onDeviceRefresh={() => safe(deviceStore().load())} onDeviceSetEnabled={(id, enabled) => safe(deviceStore().setEnabled(id, enabled))} onDeviceUninstall={(id) => safe(deviceStore().uninstall(id))} />;
-}
diff --git a/src/pages/PluginMarketplace/index.tsx b/src/pages/PluginMarketplace/index.tsx
deleted file mode 100644
index f12130e..0000000
--- a/src/pages/PluginMarketplace/index.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import { FormEvent, useEffect, useMemo, useState } from 'react';
-import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react';
-import { toast } from 'sonner';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import type {
- MarketplaceCatalogPage,
- MarketplaceCatalogQuery,
- MarketplaceLibrarySnapshot,
- MarketplacePluginDetail,
- MarketplaceUsageBilling,
-} from '@/lib/plugin-marketplace';
-import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
-import { useAuthStore } from '@/stores/auth';
-
-const USAGE_COPY: Record = {
- included: '当前包含,不按单次调用扣点',
- token_point: '按 Token Point 实际用量计费',
- mixed: '部分能力包含,部分按 Token Point 用量计费',
-};
-
-export type PluginMarketplaceViewProps = {
- catalog: MarketplaceCatalogPage | null;
- detail: MarketplacePluginDetail | null;
- library: MarketplaceLibrarySnapshot | null;
- catalogState: 'idle' | 'loading' | 'ready' | 'error';
- catalogError?: string | null;
- pending: Record;
- onSearch(input: MarketplaceCatalogQuery): void | Promise;
- onSelect(pluginId: string): void | Promise;
- onAcquire(pluginId: string): void | Promise;
-};
-
-export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
- const [query, setQuery] = useState('');
- const [category, setCategory] = useState('');
- const categories = useMemo(() => [...new Set(props.catalog?.items.map((item) => item.category) ?? [])].sort(), [props.catalog]);
- const acquiredIds = useMemo(() => new Set(props.library?.items
- .filter(({ removedAt }) => removedAt === null)
- .map(({ pluginId }) => pluginId) ?? []), [props.library]);
- const submit = (event: FormEvent) => {
- event.preventDefault();
- void props.onSearch({ ...(query.trim() ? { query: query.trim() } : {}), ...(category ? { category } : {}), limit: 24 });
- };
-
- return (
-
-
-
-
-
- {props.catalog?.stale ? 正在显示上次可信的缓存目录;刷新成功前不会把本地安装包伪装成新的目录信息。
: null}
- {props.catalogState === 'loading' && !props.catalog ? 正在读取插件目录…
: null}
- {props.catalogState === 'error' && !props.catalog ? 插件目录暂不可用
首次读取失败,不会从本地包推测目录或计费策略。{props.catalogError ? ` ${props.catalogError}` : ''}
: null}
- {props.catalog && props.catalog.items.length === 0 ? : null}
-
-
- {props.catalog?.items.map((plugin) => {
- const acquired = acquiredIds.has(plugin.pluginId);
- const acquiring = Boolean(props.pending[`acquire:${plugin.pluginId}`]);
- return (
-
-
-
{plugin.category}
{plugin.title}
- {plugin.acquisition === 'system_included' ?
系统内置 :
免费获取}
-
- {plugin.summary}
-
- - 提供方
- {plugin.providerDisplayName}
- - 稳定版
- {plugin.stableVersion ?? '暂不可用'}
-
- {USAGE_COPY[plugin.usageBilling]}
- {plugin.runtimeStatus === 'suspended' ? 运行已暂停,暂不能获取或下载。
: null}
-
-
- {plugin.acquisition === 'system_included' || acquired
- ? {plugin.acquisition === 'system_included' ? '随 MakeLore 提供' : '已免费获取'}
- : }
-
-
- );
- })}
-
-
- {props.detail ? : null}
-
- );
-}
-
-export function PluginMarketplace() {
- const user = useAuthStore((state) => state.user);
- const accountKey = user?.userId ?? user?.username ?? null;
- const catalog = usePluginMarketplaceStore((state) => state.catalog);
- const catalogState = usePluginMarketplaceStore((state) => state.catalogState);
- const catalogError = usePluginMarketplaceStore((state) => state.catalogError);
- const library = usePluginMarketplaceStore((state) => state.library);
- const details = usePluginMarketplaceStore((state) => state.details);
- const pending = usePluginMarketplaceStore((state) => state.pending);
- const [selectedId, setSelectedId] = useState(null);
- useEffect(() => {
- pluginMarketplaceStore.getState().activateAccount(accountKey);
- void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined);
- if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
- }, [accountKey]);
- const safe = (operation: Promise) => operation.catch((error) => {
- toast.error(error instanceof Error ? error.message : String(error));
- });
- return safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />;
-}
diff --git a/src/pages/Plugins/PluginDetails.tsx b/src/pages/Plugins/PluginDetails.tsx
new file mode 100644
index 0000000..785f4e8
--- /dev/null
+++ b/src/pages/Plugins/PluginDetails.tsx
@@ -0,0 +1,305 @@
+import { useState } from 'react';
+import { AlertTriangle, ExternalLink, ShieldCheck, X } from 'lucide-react';
+import { DataServicePluginSettings } from '@/components/plugins/data-service-settings';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import type { DataServiceInstanceState } from '../../../shared/data-service';
+import type { PluginWorkspaceCommand, PluginWorkspaceItem } from './plugin-workspace-model';
+
+const SOURCE_TEXT = {
+ official: '官方',
+ local: '本机',
+ retained: '配置保留',
+} as const;
+
+const DELIVERY_TEXT = {
+ system_included: '随 MakeLore 提供',
+ account_acquired: '账号已获取',
+ account_removed: '已从账号移除',
+ account_unknown: '账号状态未知',
+ local_installed: '本机已安装',
+ not_acquired: '尚未获取',
+ retained: '仅保留项目配置',
+} as const;
+
+const PROJECT_TEXT = {
+ enabled: '当前项目已启用',
+ disabled: '当前项目未启用',
+ unavailable: '当前项目暂不可用',
+ unknown: '当前项目状态未知',
+ not_applicable: '不属于项目启用范围',
+} as const;
+
+function billingText(item: PluginWorkspaceItem): string {
+ if (item.billing === 'included') return '按平台包含,不按单次插件调用扣点。';
+ if (item.billing === 'token_point') return '按 Token Point 实际用量计费,价格与回执以服务端为准。';
+ if (item.billing === 'mixed') return '部分能力包含,部分能力按 Token Point 用量计费。';
+ if (item.billing === 'none') return '无 MakeLore 平台插件计费投影;外部服务或模型调用可能另行计费。';
+ return '当前没有可信的插件计费投影。';
+}
+
+function commandLabel(command: PluginWorkspaceCommand, title: string): string {
+ switch (command.kind) {
+ case 'acquire': return `免费获取${title}`;
+ case 'reacquire': return `重新免费获取${title}`;
+ case 'remove_from_library': return `从账号移除${title}`;
+ case 'install_stable': return `下载${title}稳定版`;
+ case 'install_beta': return `安装${title} Beta`;
+ case 'update_official': return `更新${title}设备包`;
+ case 'remove_official_device_package': return `删除${title}官方设备包`;
+ case 'enable_project': return `启用${title}到当前项目`;
+ case 'disable_project': return `从当前项目禁用${title}`;
+ case 'enable_local': return `本机全局启用${title}`;
+ case 'disable_local': return `本机全局停用${title}`;
+ case 'remove_local': return `移除本机包${title}`;
+ case 'open_agent_assignment': return `分配${title}给伙伴`;
+ case 'open_settings': return `查看${title}插件设置`;
+ }
+}
+
+function destructive(command: PluginWorkspaceCommand): boolean {
+ return command.kind === 'remove_from_library'
+ || command.kind === 'remove_official_device_package'
+ || command.kind === 'disable_project'
+ || command.kind === 'remove_local';
+}
+
+function confirmationCopy(
+ command: PluginWorkspaceCommand,
+ item: PluginWorkspaceItem,
+ projectName: string | null,
+): { title: string; description: string; confirm: string } {
+ switch (command.kind) {
+ case 'remove_from_library':
+ return {
+ title: `从账号移除${item.title}?`,
+ description: '这会移除账号插件库记录,不会自动删除已经下载的设备包或项目配置。',
+ confirm: '确认从账号移除',
+ };
+ case 'remove_official_device_package':
+ return {
+ title: `删除${item.title}的官方设备包?`,
+ description: '这只删除当前设备上的官方包,不会移除账号插件库或项目配置。',
+ confirm: '确认删除官方设备包',
+ };
+ case 'disable_project':
+ return {
+ title: `从当前项目禁用${item.title}?`,
+ description: `插件能力会从项目“${projectName ?? '当前项目'}”停止,但插件数据与账号记录会保留。`,
+ confirm: '确认从当前项目禁用',
+ };
+ case 'remove_local':
+ return {
+ title: `移除本机包${item.title}?`,
+ description: '这会从本机全局移除该包,新建或空闲的主伙伴与 worker 将不再加载它。',
+ confirm: '确认移除本机包',
+ };
+ default:
+ return { title: '', description: '', confirm: '' };
+ }
+}
+
+export interface PluginDetailsProps {
+ item: PluginWorkspaceItem;
+ activeProjectName: string | null;
+ dataService: DataServiceInstanceState | null;
+ dataServicePending: Record;
+ isCommandPending(command: PluginWorkspaceCommand): boolean;
+ onClose(): void;
+ onCommand(command: PluginWorkspaceCommand): void | Promise;
+ onConfigureDataService(collections: string[]): void | Promise;
+ onResetDataService(): void | Promise;
+ onRemoveDataServiceCollection(collection: string): void | Promise;
+ onRemoveDataServiceProject(): void | Promise;
+}
+
+export function PluginDetails(props: PluginDetailsProps) {
+ const [confirmation, setConfirmation] = useState(null);
+ const project = props.item.official?.project ?? null;
+ const detail = props.item.official?.detail ?? null;
+ const installation = props.item.official?.installation ?? null;
+ const showDataService = project?.enabled
+ && project.settingsSurface === 'data-service';
+ const confirmCopy = confirmation
+ ? confirmationCopy(confirmation, props.item, props.activeProjectName)
+ : null;
+
+ const run = (command: PluginWorkspaceCommand) => {
+ if (destructive(command)) {
+ setConfirmation(command);
+ return;
+ }
+ void props.onCommand(command);
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/pages/Plugins/index.tsx b/src/pages/Plugins/index.tsx
new file mode 100644
index 0000000..a6ee99d
--- /dev/null
+++ b/src/pages/Plugins/index.tsx
@@ -0,0 +1,575 @@
+import { useEffect, useMemo, useRef, type FormEvent } from 'react';
+import { AlertCircle, Puzzle, RefreshCw, Search } from 'lucide-react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { toast } from 'sonner';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Select } from '@/components/ui/select';
+import type { DataServiceInstanceState } from '../../../shared/data-service';
+import { useAuthStore } from '@/stores/auth';
+import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
+import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
+import { devicePackageStore, useDevicePackageStore } from '@/stores/device-packages';
+import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
+import { PluginDetails } from './PluginDetails';
+import { dispatchPluginWorkspaceCommand, settlePluginWorkspaceLoads } from './plugin-workspace-controller';
+import type {
+ PluginWorkspaceCommand,
+ PluginWorkspaceFilters,
+ PluginWorkspaceItem,
+ PluginWorkspaceProjection,
+ PluginWorkspaceState,
+} from './plugin-workspace-model';
+import { buildPluginWorkspaceProjection } from './plugin-workspace-model';
+import { parsePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './plugin-workspace-query';
+
+const DELIVERY_TEXT = {
+ system_included: '随 MakeLore 提供',
+ account_acquired: '账号已获取',
+ account_removed: '已从账号移除',
+ account_unknown: '账号状态未知',
+ local_installed: '本机已安装',
+ not_acquired: '尚未获取',
+ retained: '配置保留',
+} as const;
+
+const PROJECT_TEXT = {
+ enabled: '当前项目已启用',
+ disabled: '当前项目未启用',
+ unavailable: '当前项目暂不可用',
+ unknown: '当前项目状态未知',
+ not_applicable: '不适用项目启用',
+} as const;
+
+const BILLING_TEXT = {
+ included: '包含',
+ token_point: '按 Token Point',
+ mixed: '混合',
+ none: '无平台插件计费投影',
+ unknown: '计费未知',
+} as const;
+
+const STATE_OPTIONS: ReadonlyArray<{ value: PluginWorkspaceState; label: string }> = [
+ { value: 'all', label: '全部状态' },
+ { value: 'available', label: '可获取' },
+ { value: 'mine', label: '我的' },
+ { value: 'enabled', label: '已启用' },
+ { value: 'update', label: '可更新' },
+ { value: 'unavailable', label: '不可用' },
+];
+
+export type PluginWorkspaceSourceName = 'catalog' | 'library' | 'device' | 'project' | 'detail';
+
+export interface PluginWorkspaceSourceError {
+ source: PluginWorkspaceSourceName;
+ message: string;
+}
+
+const SOURCE_ERROR_TITLE: Record = {
+ catalog: '官方目录',
+ library: '账号插件库',
+ device: '本机插件包',
+ project: '当前项目插件',
+ detail: '插件详情',
+};
+
+function cardSource(item: PluginWorkspaceItem): string {
+ if (item.source === 'official') return '官方';
+ if (item.source === 'local') return '本机';
+ return '配置保留';
+}
+
+function agentText(item: PluginWorkspaceItem): string {
+ if (item.source === 'local') return '本机全局生效';
+ const count = item.assignedAgentIds.length;
+ return count ? `已分配 ${count} 位伙伴` : '尚未分配伙伴';
+}
+
+function PluginCard({
+ item,
+ grouped = false,
+ onOpen,
+}: {
+ item: PluginWorkspaceItem;
+ grouped?: boolean;
+ onOpen(trigger: HTMLButtonElement): void;
+}) {
+ const Title = grouped ? 'h3' : 'h2';
+ return (
+
+
+ {cardSource(item)}
+ {DELIVERY_TEXT[item.delivery]}
+ {item.updateAvailable ? 可更新 : null}
+ {item.unavailable ? 不可用 : null}
+ {item.stale ? 缓存 : null}
+
+ {item.title}
+ {item.summary}
+
+ - 当前项目
- {PROJECT_TEXT[item.projectState]}
+ - 本机状态
- {item.source === 'local' ? (item.localEnabled ? '本机全局已启用' : '本机全局已停用') : item.official?.installation?.version ? `官方包 ${item.official.installation.version}` : item.delivery === 'system_included' ? '随应用提供' : '未下载官方包'}
+ - 伙伴
- {agentText(item)}
+ - 计费
- {BILLING_TEXT[item.billing]}
+
+
+
+ );
+}
+
+export interface PluginsViewProps {
+ projection: PluginWorkspaceProjection;
+ filters: PluginWorkspaceFilters;
+ activeProjectId: string | null;
+ activeProjectName: string | null;
+ sourceErrors: readonly PluginWorkspaceSourceError[];
+ sourceLoading: readonly PluginWorkspaceSourceName[];
+ dataService: DataServiceInstanceState | null;
+ dataServicePending: Record;
+ onFiltersChange(filters: PluginWorkspaceFilters): void;
+ onRefresh(): void | Promise;
+ onSelect(key: PluginWorkspaceItem['key'] | null): void;
+ onCommand(command: PluginWorkspaceCommand): void | Promise;
+ isCommandPending(command: PluginWorkspaceCommand): boolean;
+ onConfigureDataService(collections: string[]): void | Promise;
+ onResetDataService(): void | Promise;
+ onRemoveDataServiceCollection(collection: string): void | Promise;
+ onRemoveDataServiceProject(): void | Promise;
+}
+
+export function PluginsView(props: PluginsViewProps) {
+ const detailTriggerRef = useRef(null);
+ const openDetails = (item: PluginWorkspaceItem, trigger: HTMLButtonElement) => {
+ detailTriggerRef.current = trigger;
+ props.onSelect(item.key);
+ };
+ const updateFilters = (patch: Partial) => {
+ props.onFiltersChange({ ...props.filters, ...patch, selectedKey: null });
+ };
+ const search = (event: FormEvent) => {
+ event.preventDefault();
+ const data = new FormData(event.currentTarget);
+ updateFilters({ search: String(data.get('plugin-search') ?? '').trim() });
+ };
+ const closeDetails = () => {
+ const trigger = detailTriggerRef.current;
+ props.onSelect(null);
+ window.setTimeout(() => {
+ if (trigger?.isConnected) trigger.focus();
+ }, 0);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {props.projection.notices.map((notice) => (
+
+ {notice.message}
+
+ ))}
+ {props.sourceLoading.length ? (
+
+ 正在刷新 {props.sourceLoading.map((source) => SOURCE_ERROR_TITLE[source]).join('、')}…
+
+ ) : null}
+ {props.sourceErrors.length ? (
+
+ {props.sourceErrors.map((error) => (
+
+
+ {SOURCE_ERROR_TITLE[error.source]}刷新失败。 {error.message}
+
+ ))}
+
+ ) : null}
+
+ {props.projection.items.length ? (
+
+ {props.filters.scope === 'project' ? (
+
+ {([
+ { id: 'project', label: '当前项目插件', items: props.projection.items.filter((item) => item.source === 'official') },
+ { id: 'local', label: '本机全局生效', items: props.projection.items.filter((item) => item.source === 'local') },
+ { id: 'retained', label: '配置保留', items: props.projection.items.filter((item) => item.source === 'retained') },
+ ] as const).map((group) => group.items.length ? (
+
+ {group.label}
+
+ {group.items.map((item) => (
+
openDetails(item, trigger)} />
+ ))}
+
+
+ ) : null)}
+
+ ) : (
+
+ {props.projection.items.map((item) => (
+
openDetails(item, trigger)} />
+ ))}
+
+ )}
+
+ ) : (
+
+
+ 没有符合筛选条件的插件
+ 调整范围、来源、状态或搜索词后再试。
+
+ )}
+
+ {props.projection.selected ? (
+
+ ) : null}
+
+ );
+}
+
+function pluginIdFromKey(key: PluginWorkspaceItem['key'] | null | undefined): string | null {
+ if (!key?.startsWith('official:')) return null;
+ return key.slice('official:'.length) || null;
+}
+
+function isSettled(state: 'idle' | 'loading' | 'ready' | 'error'): boolean {
+ return state === 'ready' || state === 'error';
+}
+
+export function Plugins() {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const canonicalized = useRef(false);
+ const user = useAuthStore((state) => state.user);
+ const accountKey = user?.userId ?? user?.username ?? null;
+ const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
+ const workspaceConfig = useCodingWorkspaceStore((state) => state.config);
+ const workspaceLoadState = useCodingWorkspaceStore((state) => state.loadState);
+
+ const catalog = usePluginMarketplaceStore((state) => state.catalog);
+ const catalogState = usePluginMarketplaceStore((state) => state.catalogState);
+ const catalogError = usePluginMarketplaceStore((state) => state.catalogError);
+ const library = usePluginMarketplaceStore((state) => state.library);
+ const libraryState = usePluginMarketplaceStore((state) => state.libraryState);
+ const libraryError = usePluginMarketplaceStore((state) => state.libraryError);
+ const installations = usePluginMarketplaceStore((state) => state.installations);
+ const details = usePluginMarketplaceStore((state) => state.details);
+ const detailState = usePluginMarketplaceStore((state) => state.detailState);
+ const marketplacePending = usePluginMarketplaceStore((state) => state.pending);
+
+ const devicePackages = useDevicePackageStore((state) => state.index);
+ const deviceState = useDevicePackageStore((state) => state.state);
+ const deviceError = useDevicePackageStore((state) => state.error);
+ const devicePending = useDevicePackageStore((state) => state.pending);
+
+ const projectProjection = useCodingPluginsStore((state) => state.projection);
+ const dataService = useCodingPluginsStore((state) => state.dataService);
+ const projectLoadState = useCodingPluginsStore((state) => state.loadState);
+ const projectError = useCodingPluginsStore((state) => state.error);
+ const projectPending = useCodingPluginsStore((state) => state.pending);
+
+ const workspaceHydrated = workspaceLoadState === 'ready' || workspaceLoadState === 'error';
+ const queryString = searchParams.toString();
+ const filters = useMemo(
+ () => parsePluginWorkspaceSearch(new URLSearchParams(queryString), Boolean(activeProject)),
+ [activeProject, queryString],
+ );
+ const currentProjectProjection = activeProject
+ && projectProjection?.project.localProjectId === activeProject.id
+ ? projectProjection
+ : null;
+ const agentNames = useMemo(
+ () => Object.fromEntries((workspaceConfig?.agents ?? []).map((agent) => [agent.id, agent.name || agent.roleName])),
+ [workspaceConfig],
+ );
+ const projection = useMemo(() => buildPluginWorkspaceProjection({
+ catalog,
+ details,
+ library,
+ marketplaceInstallations: installations,
+ devicePackages,
+ project: currentProjectProjection,
+ activeProject: activeProject ? { id: activeProject.id, name: activeProject.name } : null,
+ agentNames,
+ filters,
+ }), [
+ activeProject,
+ agentNames,
+ catalog,
+ currentProjectProjection,
+ details,
+ devicePackages,
+ filters,
+ installations,
+ library,
+ ]);
+
+ useEffect(() => {
+ if (!workspaceHydrated || canonicalized.current) return;
+ canonicalized.current = true;
+ const canonical = serializePluginWorkspaceSearch(filters);
+ if (canonical.toString() !== queryString) setSearchParams(canonical, { replace: true });
+ }, [filters, queryString, setSearchParams, workspaceHydrated]);
+
+ useEffect(() => {
+ if (!workspaceHydrated) return;
+ void pluginMarketplaceStore.getState().loadCatalog({
+ limit: 24,
+ ...(filters.search ? { query: filters.search } : {}),
+ }).catch(() => undefined);
+ }, [filters.search, workspaceHydrated]);
+
+ useEffect(() => {
+ pluginMarketplaceStore.getState().activateAccount(accountKey);
+ if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
+ }, [accountKey]);
+
+ useEffect(() => {
+ if (!workspaceHydrated) return;
+ void devicePackageStore.getState().load().catch(() => undefined);
+ }, [workspaceHydrated]);
+
+ useEffect(() => {
+ if (!workspaceHydrated || !activeProject) return;
+ void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined);
+ }, [activeProject, workspaceHydrated]);
+
+ const selectedOfficialId = pluginIdFromKey(filters.selectedKey);
+ useEffect(() => {
+ if (!selectedOfficialId || details[selectedOfficialId] || detailState[selectedOfficialId] === 'loading') return;
+ void pluginMarketplaceStore.getState().loadDetail(selectedOfficialId).catch(() => undefined);
+ }, [detailState, details, selectedOfficialId]);
+
+ useEffect(() => {
+ if (!filters.selectedKey || projection.selected) return;
+ const resolved = filters.selectedKey.startsWith('local:')
+ ? isSettled(deviceState)
+ : filters.selectedKey.startsWith('retained:')
+ ? !activeProject || isSettled(projectLoadState)
+ : isSettled(catalogState)
+ && (!accountKey || isSettled(libraryState))
+ && (!activeProject || isSettled(projectLoadState));
+ if (!resolved) return;
+ setSearchParams(serializePluginWorkspaceSearch({ ...filters, selectedKey: null }), { replace: true });
+ }, [
+ accountKey,
+ activeProject,
+ catalogState,
+ deviceState,
+ filters,
+ libraryState,
+ projectLoadState,
+ projection.selected,
+ setSearchParams,
+ ]);
+
+ const sourceErrors = useMemo(() => {
+ const errors: PluginWorkspaceSourceError[] = [];
+ if (catalogState === 'error') errors.push({ source: 'catalog', message: catalogError ?? '请稍后重试。' });
+ if (accountKey && libraryState === 'error') errors.push({ source: 'library', message: libraryError ?? '请稍后重试。' });
+ if (deviceState === 'error') errors.push({ source: 'device', message: deviceError ?? '请稍后重试。' });
+ if (activeProject && projectLoadState === 'error') errors.push({ source: 'project', message: projectError ?? '请稍后重试。' });
+ if (selectedOfficialId && detailState[selectedOfficialId] === 'error') {
+ errors.push({ source: 'detail', message: catalogError ?? '请稍后重试。' });
+ }
+ return errors;
+ }, [
+ accountKey,
+ activeProject,
+ catalogError,
+ catalogState,
+ detailState,
+ deviceError,
+ deviceState,
+ libraryError,
+ libraryState,
+ projectError,
+ projectLoadState,
+ selectedOfficialId,
+ ]);
+ const sourceLoading = useMemo(() => {
+ const loading: PluginWorkspaceSourceName[] = [];
+ if (catalogState === 'loading') loading.push('catalog');
+ if (accountKey && libraryState === 'loading') loading.push('library');
+ if (deviceState === 'loading') loading.push('device');
+ if (activeProject && projectLoadState === 'loading') loading.push('project');
+ if (selectedOfficialId && detailState[selectedOfficialId] === 'loading') loading.push('detail');
+ return loading;
+ }, [accountKey, activeProject, catalogState, detailState, deviceState, libraryState, projectLoadState, selectedOfficialId]);
+
+ const setFilters = (next: PluginWorkspaceFilters) => {
+ setSearchParams(serializePluginWorkspaceSearch(next));
+ };
+ const select = (key: PluginWorkspaceItem['key'] | null) => {
+ setFilters({ ...filters, selectedKey: key });
+ };
+ const safe = async (operation: Promise) => {
+ try {
+ await operation;
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : String(error));
+ }
+ };
+ const commandPending = (command: PluginWorkspaceCommand): boolean => {
+ switch (command.kind) {
+ case 'acquire':
+ case 'reacquire': return Boolean(marketplacePending[`acquire:${command.pluginId}`]);
+ case 'remove_from_library': return Boolean(marketplacePending[`remove:${command.pluginId}`]);
+ case 'install_stable': return Boolean(marketplacePending[`install:${command.pluginId}`]);
+ case 'install_beta': return Boolean(marketplacePending[`install-beta:${command.pluginId}`]);
+ case 'update_official': return Boolean(marketplacePending[`update:${command.pluginId}`]);
+ case 'remove_official_device_package': return Boolean(marketplacePending[`uninstall:${command.pluginId}`]);
+ case 'enable_project':
+ case 'disable_project': return Boolean(projectPending[`enabled:${command.projectId}:${command.pluginId}`]);
+ case 'enable_local':
+ case 'disable_local': return Boolean(devicePending[`enabled:${command.packageId}`]);
+ case 'remove_local': return Boolean(devicePending[`uninstall:${command.packageId}`]);
+ case 'open_agent_assignment':
+ case 'open_settings': return false;
+ }
+ };
+ const refresh = () => settlePluginWorkspaceLoads({
+ catalog: async () => await pluginMarketplaceStore.getState().loadCatalog({
+ limit: 24,
+ ...(filters.search ? { query: filters.search } : {}),
+ }),
+ ...(accountKey ? { library: async () => await pluginMarketplaceStore.getState().loadLibrary() } : {}),
+ device: async () => await devicePackageStore.getState().load(),
+ ...(activeProject ? { project: async () => await codingPluginsStore.getState().load(activeProject.id) } : {}),
+ }).then(() => undefined);
+ const dispatch = (command: PluginWorkspaceCommand) => safe(dispatchPluginWorkspaceCommand(command, {
+ marketplace: pluginMarketplaceStore.getState(),
+ device: devicePackageStore.getState(),
+ project: codingPluginsStore.getState(),
+ navigate: (path) => navigate(path),
+ openSettings: () => document.getElementById('plugin-settings')?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
+ }));
+
+ if (!workspaceHydrated) {
+ return 正在读取项目工作区…
;
+ }
+
+ return (
+ safe(codingPluginsStore.getState().configure(collections))}
+ onResetDataService={() => safe(codingPluginsStore.getState().reset())}
+ onRemoveDataServiceCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))}
+ onRemoveDataServiceProject={() => safe(codingPluginsStore.getState().removeProject())}
+ />
+ );
+}
diff --git a/src/pages/Plugins/legacy-plugin-redirect.tsx b/src/pages/Plugins/legacy-plugin-redirect.tsx
new file mode 100644
index 0000000..bbc167b
--- /dev/null
+++ b/src/pages/Plugins/legacy-plugin-redirect.tsx
@@ -0,0 +1,13 @@
+import { Navigate } from 'react-router-dom';
+
+export type LegacyPluginPath = '/plugin-marketplace' | '/my-plugins' | '/project-plugins';
+
+const LEGACY_PLUGIN_REDIRECTS: Record = {
+ '/plugin-marketplace': '/plugins?scope=all&source=official',
+ '/my-plugins': '/plugins?scope=all&state=mine',
+ '/project-plugins': '/plugins?scope=project',
+};
+
+export function LegacyPluginRedirect({ from }: { from: LegacyPluginPath }) {
+ return ;
+}
diff --git a/src/pages/Plugins/plugin-workspace-controller.ts b/src/pages/Plugins/plugin-workspace-controller.ts
new file mode 100644
index 0000000..0214c3c
--- /dev/null
+++ b/src/pages/Plugins/plugin-workspace-controller.ts
@@ -0,0 +1,78 @@
+import type { CodingPluginsState } from '@/stores/coding-plugins';
+import type { DevicePackageState } from '@/stores/device-packages';
+import type { PluginMarketplaceState } from '@/stores/plugin-marketplace';
+import type { PluginWorkspaceCommand } from './plugin-workspace-model';
+
+export interface PluginWorkspaceDispatchDependencies {
+ marketplace: Pick<
+ PluginMarketplaceState,
+ 'acquire' | 'remove' | 'install' | 'installBeta' | 'update' | 'uninstall'
+ >;
+ device: Pick;
+ project: Pick;
+ navigate(path: string): void;
+ openSettings(): void;
+}
+
+export async function dispatchPluginWorkspaceCommand(
+ command: PluginWorkspaceCommand,
+ dependencies: PluginWorkspaceDispatchDependencies,
+): Promise {
+ switch (command.kind) {
+ case 'acquire':
+ case 'reacquire':
+ await dependencies.marketplace.acquire(command.pluginId);
+ return;
+ case 'remove_from_library':
+ await dependencies.marketplace.remove(command.pluginId);
+ return;
+ case 'install_stable':
+ await dependencies.marketplace.install(command.pluginId);
+ return;
+ case 'install_beta':
+ await dependencies.marketplace.installBeta(command.pluginId);
+ return;
+ case 'update_official':
+ await dependencies.marketplace.update(command.pluginId);
+ return;
+ case 'remove_official_device_package':
+ await dependencies.marketplace.uninstall(command.pluginId);
+ return;
+ case 'enable_project':
+ await dependencies.project.setEnabled(command.projectId, command.pluginId, true);
+ return;
+ case 'disable_project':
+ await dependencies.project.setEnabled(command.projectId, command.pluginId, false);
+ return;
+ case 'enable_local':
+ await dependencies.device.setEnabled(command.packageId, true);
+ return;
+ case 'disable_local':
+ await dependencies.device.setEnabled(command.packageId, false);
+ return;
+ case 'remove_local':
+ await dependencies.device.uninstall(command.packageId);
+ return;
+ case 'open_agent_assignment':
+ dependencies.navigate('/project-config');
+ return;
+ case 'open_settings':
+ dependencies.openSettings();
+ return;
+ }
+}
+
+export interface PluginWorkspaceLoaders {
+ catalog(): Promise;
+ library?: () => Promise;
+ device(): Promise;
+ project?: () => Promise;
+}
+
+export function settlePluginWorkspaceLoads(
+ loaders: PluginWorkspaceLoaders,
+): Promise[]> {
+ const available = [loaders.catalog, loaders.library, loaders.device, loaders.project]
+ .filter((load): load is () => Promise => Boolean(load));
+ return Promise.allSettled(available.map((load) => load()));
+}
diff --git a/src/pages/Plugins/plugin-workspace-model.ts b/src/pages/Plugins/plugin-workspace-model.ts
new file mode 100644
index 0000000..1b5bda6
--- /dev/null
+++ b/src/pages/Plugins/plugin-workspace-model.ts
@@ -0,0 +1,451 @@
+import type { CodingPluginItem, CodingPluginProject } from '@/lib/coding-plugins';
+import type {
+ MarketplaceCatalogItem,
+ MarketplaceCatalogPage,
+ MarketplaceInstallation,
+ MarketplaceLibraryEntry,
+ MarketplaceLibrarySnapshot,
+ MarketplacePluginDetail,
+ MarketplaceUsageBilling,
+} from '@/lib/plugin-marketplace';
+import type { DevicePackageIndexV1, DevicePackageRecordV1 } from '../../../shared/device-packages';
+import { isCodeOwnedOptionalBundledPluginId } from '../../../shared/coding-plugins';
+
+export type PluginWorkspaceScope = 'all' | 'project';
+export type PluginWorkspaceSource = 'all' | 'official' | 'local';
+export type PluginWorkspaceState =
+ | 'all'
+ | 'available'
+ | 'mine'
+ | 'enabled'
+ | 'update'
+ | 'unavailable';
+
+export type PluginWorkspaceKey =
+ | `official:${string}`
+ | `local:${string}`
+ | `retained:${string}`;
+
+export interface PluginWorkspaceFilters {
+ scope: PluginWorkspaceScope;
+ source: PluginWorkspaceSource;
+ state: PluginWorkspaceState;
+ search: string;
+ selectedKey?: PluginWorkspaceKey | null;
+}
+
+export type PluginWorkspaceCommand =
+ | { kind: 'acquire'; pluginId: string }
+ | { kind: 'reacquire'; pluginId: string }
+ | { kind: 'remove_from_library'; pluginId: string }
+ | { kind: 'install_stable'; pluginId: string }
+ | { kind: 'install_beta'; pluginId: string }
+ | { kind: 'update_official'; pluginId: string }
+ | { kind: 'remove_official_device_package'; pluginId: string }
+ | { kind: 'enable_project'; projectId: string; pluginId: string }
+ | { kind: 'disable_project'; projectId: string; pluginId: string }
+ | { kind: 'enable_local'; packageId: string }
+ | { kind: 'disable_local'; packageId: string }
+ | { kind: 'remove_local'; packageId: string }
+ | { kind: 'open_agent_assignment'; projectId: string; pluginId: string }
+ | { kind: 'open_settings'; projectId: string; pluginId: string };
+
+export type PluginWorkspaceDelivery =
+ | 'system_included'
+ | 'account_acquired'
+ | 'account_removed'
+ | 'account_unknown'
+ | 'local_installed'
+ | 'not_acquired'
+ | 'retained';
+
+export type PluginWorkspaceProjectState =
+ | 'enabled'
+ | 'disabled'
+ | 'unavailable'
+ | 'unknown'
+ | 'not_applicable';
+
+export interface PluginWorkspaceOfficialSources {
+ catalog: MarketplaceCatalogItem | null;
+ detail: MarketplacePluginDetail | null;
+ library: MarketplaceLibraryEntry | null;
+ installation: MarketplaceInstallation | null;
+ project: CodingPluginItem | null;
+}
+
+export interface PluginWorkspaceItem {
+ key: PluginWorkspaceKey;
+ source: 'official' | 'local' | 'retained';
+ pluginId: string;
+ packageId: string | null;
+ title: string;
+ summary: string;
+ category: string | null;
+ tags: readonly string[];
+ publisher: string | null;
+ version: string | null;
+ delivery: PluginWorkspaceDelivery;
+ projectState: PluginWorkspaceProjectState;
+ localEnabled: boolean | null;
+ assignedAgentIds: readonly string[];
+ assignedAgentNames: readonly string[];
+ billing: MarketplaceUsageBilling | 'none' | 'unknown';
+ updateAvailable: boolean;
+ unavailable: boolean;
+ stale: boolean;
+ commands: readonly PluginWorkspaceCommand[];
+ official: PluginWorkspaceOfficialSources | null;
+ local: DevicePackageRecordV1 | null;
+}
+
+export type PluginWorkspaceNotice =
+ | { kind: 'scope_fallback'; message: string }
+ | { kind: 'source_unavailable'; source: 'catalog' | 'library' | 'device' | 'project'; message: string }
+ | { kind: 'stale'; source: 'catalog' | 'library' | 'detail'; message: string };
+
+export interface PluginWorkspaceInputs {
+ catalog: MarketplaceCatalogPage | null;
+ details: Readonly>;
+ library: MarketplaceLibrarySnapshot | null;
+ marketplaceInstallations: Readonly>;
+ devicePackages: DevicePackageIndexV1 | null;
+ project: CodingPluginProject | null;
+ activeProject: { id: string; name: string } | null;
+ agentNames: Readonly>;
+ filters: PluginWorkspaceFilters;
+}
+
+export interface PluginWorkspaceProjection {
+ items: readonly PluginWorkspaceItem[];
+ selected: PluginWorkspaceItem | null;
+ counts: Readonly>;
+ notices: readonly PluginWorkspaceNotice[];
+}
+
+interface OfficialAccumulator {
+ pluginId: string;
+ catalog: MarketplaceCatalogItem | null;
+ detail: MarketplacePluginDetail | null;
+ library: MarketplaceLibraryEntry | null;
+ installation: MarketplaceInstallation | null;
+ project: CodingPluginItem | null;
+}
+
+const NATIVE_WEB_SEARCH_ID = 'makelore_web_search';
+
+function officialAccumulator(
+ records: Map,
+ pluginId: string,
+): OfficialAccumulator {
+ const existing = records.get(pluginId);
+ if (existing) return existing;
+ const created: OfficialAccumulator = {
+ pluginId,
+ catalog: null,
+ detail: null,
+ library: null,
+ installation: null,
+ project: null,
+ };
+ records.set(pluginId, created);
+ return created;
+}
+
+function currentLibraryEntry(entry: MarketplaceLibraryEntry | null): boolean {
+ return entry !== null && entry.removedAt === null;
+}
+
+function installed(installation: MarketplaceInstallation | null): boolean {
+ return installation?.status === 'installed' || installation?.status === 'kept';
+}
+
+function updateAvailable(record: OfficialAccumulator): boolean {
+ if (!installed(record.installation) || !record.installation?.version) return false;
+ const latest = record.installation.channel === 'beta'
+ ? (record.detail?.betaRelease?.version ?? record.catalog?.betaVersion ?? record.library?.betaVersion)
+ : (record.detail?.stableRelease?.version ?? record.catalog?.stableVersion ?? record.library?.stableVersion);
+ return latest !== null && latest !== undefined && latest !== record.installation.version;
+}
+
+function assignedAgents(
+ project: CodingPluginItem | null,
+ names: Readonly>,
+): { ids: string[]; names: string[] } {
+ if (!project) return { ids: [], names: [] };
+ const ids = [...new Set(project.skills.flatMap((skill) => skill.assignedAgentIds))];
+ return { ids, names: ids.map((id) => names[id] ?? id) };
+}
+
+function officialCommands(
+ record: OfficialAccumulator,
+ input: PluginWorkspaceInputs,
+): PluginWorkspaceCommand[] {
+ const commands: PluginWorkspaceCommand[] = [];
+ const libraryCurrent = currentLibraryEntry(record.library);
+ const removed = record.library?.removedAt !== null && record.library?.removedAt !== undefined;
+ const systemIncluded = record.library?.acquisitionMode === 'system_included'
+ || record.catalog?.acquisition === 'system_included';
+ const bundledDelivery = systemIncluded || isCodeOwnedOptionalBundledPluginId(record.pluginId);
+ const sourceAvailable = record.catalog?.runtimeStatus !== 'suspended'
+ && record.library?.runtimeStatus !== 'suspended'
+ && record.library?.catalogStatus !== 'retired';
+ const hasDevicePackage = !bundledDelivery && installed(record.installation);
+ const deliveryReady = systemIncluded
+ || (bundledDelivery && libraryCurrent)
+ || (libraryCurrent && hasDevicePackage);
+
+ if (!systemIncluded && libraryCurrent) {
+ commands.push({ kind: 'remove_from_library', pluginId: record.pluginId });
+ } else if (!systemIncluded && removed && sourceAvailable) {
+ commands.push({ kind: 'reacquire', pluginId: record.pluginId });
+ } else if (!systemIncluded && input.library && !record.library
+ && record.catalog?.acquisition === 'free' && sourceAvailable) {
+ commands.push({ kind: 'acquire', pluginId: record.pluginId });
+ }
+
+ if (!bundledDelivery && libraryCurrent && sourceAvailable) {
+ if (!hasDevicePackage && (record.detail?.stableRelease || record.catalog?.stableVersion || record.library?.stableVersion)) {
+ commands.push({ kind: 'install_stable', pluginId: record.pluginId });
+ }
+ const betaAvailable = Boolean(record.detail?.betaRelease || record.catalog?.betaVersion || record.library?.betaVersion);
+ const betaChannel = record.installation?.channel === 'beta';
+ if (betaAvailable && (!betaChannel || updateAvailable(record))) {
+ commands.push({ kind: 'install_beta', pluginId: record.pluginId });
+ }
+ if (hasDevicePackage && !betaChannel && updateAvailable(record)) {
+ commands.push({ kind: 'update_official', pluginId: record.pluginId });
+ }
+ }
+ if (hasDevicePackage) {
+ commands.push({ kind: 'remove_official_device_package', pluginId: record.pluginId });
+ }
+
+ if (record.project && input.activeProject) {
+ if (record.project.enabled) {
+ commands.push({ kind: 'disable_project', projectId: input.activeProject.id, pluginId: record.pluginId });
+ } else if (deliveryReady && sourceAvailable && record.project.state !== 'unavailable') {
+ commands.push({ kind: 'enable_project', projectId: input.activeProject.id, pluginId: record.pluginId });
+ }
+ if (deliveryReady && sourceAvailable) {
+ commands.push({ kind: 'open_agent_assignment', projectId: input.activeProject.id, pluginId: record.pluginId });
+ }
+ if (record.project.enabled && deliveryReady && sourceAvailable && record.project.settingsSurface) {
+ commands.push({ kind: 'open_settings', projectId: input.activeProject.id, pluginId: record.pluginId });
+ }
+ }
+ return commands;
+}
+
+function officialItem(
+ record: OfficialAccumulator,
+ input: PluginWorkspaceInputs,
+): PluginWorkspaceItem {
+ const base = record.detail ?? record.catalog ?? record.library;
+ const agents = assignedAgents(record.project, input.agentNames);
+ const systemIncluded = record.library?.acquisitionMode === 'system_included'
+ || record.catalog?.acquisition === 'system_included';
+ const current = currentLibraryEntry(record.library);
+ const projectState: PluginWorkspaceProjectState = !input.activeProject
+ ? 'not_applicable'
+ : !record.project
+ ? 'unknown'
+ : record.project.state === 'unavailable'
+ ? 'unavailable'
+ : record.project.enabled ? 'enabled' : 'disabled';
+ return {
+ key: `official:${record.pluginId}`,
+ source: 'official',
+ pluginId: record.pluginId,
+ packageId: null,
+ title: base?.title ?? record.project?.displayName ?? record.pluginId,
+ summary: base?.summary ?? record.project?.description ?? '',
+ category: base?.category ?? null,
+ tags: record.detail?.tags ?? record.catalog?.tags ?? [],
+ publisher: record.detail?.providerDisplayName ?? record.catalog?.providerDisplayName ?? null,
+ version: record.installation?.version ?? record.project?.version ?? base?.stableVersion ?? null,
+ delivery: systemIncluded
+ ? 'system_included'
+ : current
+ ? 'account_acquired'
+ : record.library?.removedAt
+ ? 'account_removed'
+ : input.library ? 'not_acquired' : 'account_unknown',
+ projectState,
+ localEnabled: null,
+ assignedAgentIds: agents.ids,
+ assignedAgentNames: agents.names,
+ billing: record.detail?.usageBilling ?? record.catalog?.usageBilling ?? 'unknown',
+ updateAvailable: updateAvailable(record),
+ unavailable: record.catalog?.runtimeStatus === 'suspended'
+ || record.library?.runtimeStatus === 'suspended'
+ || record.library?.catalogStatus === 'retired'
+ || record.installation?.status === 'unavailable'
+ || record.project?.state === 'unavailable',
+ stale: Boolean(record.detail?.stale || input.catalog?.stale || input.library?.stale),
+ commands: officialCommands(record, input),
+ official: {
+ catalog: record.catalog,
+ detail: record.detail,
+ library: record.library,
+ installation: record.installation,
+ project: record.project,
+ },
+ local: null,
+ };
+}
+
+function localItem(local: DevicePackageRecordV1): PluginWorkspaceItem {
+ return {
+ key: `local:${local.packageId}`,
+ source: 'local',
+ pluginId: local.packageId,
+ packageId: local.packageId,
+ title: local.displayName,
+ summary: local.source.requested,
+ category: null,
+ tags: local.skillEntries.map((skill) => skill.id),
+ publisher: null,
+ version: local.resolvedVersion,
+ delivery: 'local_installed',
+ projectState: 'not_applicable',
+ localEnabled: local.enabled,
+ assignedAgentIds: [],
+ assignedAgentNames: [],
+ billing: 'none',
+ updateAvailable: false,
+ unavailable: false,
+ stale: false,
+ commands: [
+ { kind: local.enabled ? 'disable_local' : 'enable_local', packageId: local.packageId },
+ { kind: 'remove_local', packageId: local.packageId },
+ ],
+ official: null,
+ local,
+ };
+}
+
+function retainedItem(
+ pluginId: string,
+ activeProject: PluginWorkspaceInputs['activeProject'],
+): PluginWorkspaceItem {
+ return {
+ key: `retained:${pluginId}`,
+ source: 'retained',
+ pluginId,
+ packageId: null,
+ title: pluginId,
+ summary: '项目配置仍保留此插件 ID,但当前来源无法解释它。',
+ category: null,
+ tags: [],
+ publisher: null,
+ version: null,
+ delivery: 'retained',
+ projectState: 'unavailable',
+ localEnabled: null,
+ assignedAgentIds: [],
+ assignedAgentNames: [],
+ billing: 'unknown',
+ updateAvailable: false,
+ unavailable: true,
+ stale: false,
+ commands: activeProject
+ ? [{ kind: 'disable_project', projectId: activeProject.id, pluginId }]
+ : [],
+ official: null,
+ local: null,
+ };
+}
+
+function orderedUnique(values: readonly string[]): string[] {
+ return [...new Set(values)];
+}
+
+function matchesState(item: PluginWorkspaceItem, state: Exclude): boolean {
+ switch (state) {
+ case 'available':
+ return item.source === 'official'
+ && (item.delivery === 'not_acquired' || item.delivery === 'account_removed')
+ && !item.unavailable;
+ case 'mine':
+ return item.source === 'local'
+ || item.delivery === 'system_included'
+ || item.delivery === 'account_acquired';
+ case 'enabled':
+ return item.projectState === 'enabled' || item.localEnabled === true;
+ case 'update':
+ return item.updateAvailable;
+ case 'unavailable':
+ return item.unavailable;
+ }
+}
+
+function matchesSearch(item: PluginWorkspaceItem, search: string): boolean {
+ const query = search.trim().toLocaleLowerCase();
+ if (!query) return true;
+ return [item.title, item.summary, item.pluginId, item.packageId ?? '', ...item.tags]
+ .some((value) => value.toLocaleLowerCase().includes(query));
+}
+
+function stateCounts(items: readonly PluginWorkspaceItem[]): Record {
+ return {
+ all: items.length,
+ available: items.filter((item) => matchesState(item, 'available')).length,
+ mine: items.filter((item) => matchesState(item, 'mine')).length,
+ enabled: items.filter((item) => matchesState(item, 'enabled')).length,
+ update: items.filter((item) => matchesState(item, 'update')).length,
+ unavailable: items.filter((item) => matchesState(item, 'unavailable')).length,
+ };
+}
+
+export function buildPluginWorkspaceProjection(input: PluginWorkspaceInputs): PluginWorkspaceProjection {
+ const records = new Map();
+ for (const item of input.catalog?.items ?? []) officialAccumulator(records, item.pluginId).catalog = item;
+ for (const detail of Object.values(input.details)) officialAccumulator(records, detail.pluginId).detail = detail;
+ for (const item of input.library?.items ?? []) officialAccumulator(records, item.pluginId).library = item;
+ for (const installation of Object.values(input.marketplaceInstallations)) {
+ officialAccumulator(records, installation.pluginId).installation = installation;
+ }
+ for (const item of input.project?.items ?? []) officialAccumulator(records, item.id).project = item;
+
+ records.delete(NATIVE_WEB_SEARCH_ID);
+ const allOfficialOrder = orderedUnique([
+ ...(input.catalog?.items.map(({ pluginId }) => pluginId) ?? []),
+ ...(input.library?.items.map(({ pluginId }) => pluginId) ?? []),
+ ...(input.project?.items.map(({ id }) => id) ?? []),
+ ...Object.values(input.details).map(({ pluginId }) => pluginId),
+ ...Object.values(input.marketplaceInstallations).map(({ pluginId }) => pluginId),
+ ]).filter((pluginId) => pluginId !== NATIVE_WEB_SEARCH_ID);
+ const projectOfficialOrder = orderedUnique(input.project?.items.map(({ id }) => id) ?? [])
+ .filter((pluginId) => pluginId !== NATIVE_WEB_SEARCH_ID);
+ const effectiveScope = input.filters.scope === 'project' && input.activeProject ? 'project' : 'all';
+ const officialOrder = effectiveScope === 'project' ? projectOfficialOrder : allOfficialOrder;
+ const officialItems = officialOrder.flatMap((pluginId) => {
+ const record = records.get(pluginId);
+ return record ? [officialItem(record, input)] : [];
+ });
+ const localItems = [...(input.devicePackages?.packages ?? [])]
+ .sort((left, right) => left.displayName.localeCompare(right.displayName))
+ .map(localItem);
+ const retainedItems = orderedUnique(input.project?.unknownPluginIds ?? [])
+ .filter((pluginId) => pluginId !== NATIVE_WEB_SEARCH_ID && !records.has(pluginId))
+ .map((pluginId) => retainedItem(pluginId, input.activeProject));
+ const scopedItems = [...officialItems, ...localItems, ...retainedItems];
+ const sourceAndSearchItems = scopedItems.filter((item) => {
+ const sourceMatches = input.filters.source === 'all' || item.source === input.filters.source;
+ return sourceMatches && matchesSearch(item, input.filters.search);
+ });
+ const counts = stateCounts(sourceAndSearchItems);
+ const selectedState = input.filters.state;
+ const items = selectedState === 'all'
+ ? sourceAndSearchItems
+ : sourceAndSearchItems.filter((item) => matchesState(item, selectedState));
+ const selected = input.filters.selectedKey
+ ? items.find((item) => item.key === input.filters.selectedKey) ?? null
+ : null;
+ const notices: PluginWorkspaceNotice[] = [];
+ if (input.filters.scope === 'project' && !input.activeProject) {
+ notices.push({ kind: 'scope_fallback', message: '当前没有打开的项目,已显示全部插件。' });
+ }
+ return { items, selected, counts, notices };
+}
diff --git a/src/pages/Plugins/plugin-workspace-query.ts b/src/pages/Plugins/plugin-workspace-query.ts
new file mode 100644
index 0000000..4aff764
--- /dev/null
+++ b/src/pages/Plugins/plugin-workspace-query.ts
@@ -0,0 +1,54 @@
+import type {
+ PluginWorkspaceFilters,
+ PluginWorkspaceKey,
+ PluginWorkspaceScope,
+ PluginWorkspaceSource,
+ PluginWorkspaceState,
+} from './plugin-workspace-model';
+
+const SCOPES: readonly PluginWorkspaceScope[] = ['all', 'project'];
+const SOURCES: readonly PluginWorkspaceSource[] = ['all', 'official', 'local'];
+const STATES: readonly PluginWorkspaceState[] = [
+ 'all', 'available', 'mine', 'enabled', 'update', 'unavailable',
+];
+
+function member(value: string | null, values: readonly T[]): value is T {
+ return value !== null && values.includes(value as T);
+}
+
+function pluginKey(value: string | null): PluginWorkspaceKey | null {
+ if (!value) return null;
+ if (value.startsWith('official:') && value.length > 'official:'.length) return value as `official:${string}`;
+ if (value.startsWith('local:') && value.length > 'local:'.length) return value as `local:${string}`;
+ if (value.startsWith('retained:') && value.length > 'retained:'.length) return value as `retained:${string}`;
+ return null;
+}
+
+export function parsePluginWorkspaceSearch(
+ search: URLSearchParams,
+ hasActiveProject: boolean,
+): PluginWorkspaceFilters {
+ const defaultScope: PluginWorkspaceScope = hasActiveProject ? 'project' : 'all';
+ const requestedScope = search.get('scope');
+ const requestedSource = search.get('source');
+ const requestedState = search.get('state');
+ const parsedScope = member(requestedScope, SCOPES) ? requestedScope : defaultScope;
+ return {
+ scope: parsedScope === 'project' && !hasActiveProject ? 'all' : parsedScope,
+ source: member(requestedSource, SOURCES) ? requestedSource : 'all',
+ state: member(requestedState, STATES) ? requestedState : 'all',
+ search: search.get('q')?.trim() ?? '',
+ selectedKey: pluginKey(search.get('plugin')),
+ };
+}
+
+export function serializePluginWorkspaceSearch(filters: PluginWorkspaceFilters): URLSearchParams {
+ const search = new URLSearchParams();
+ search.set('scope', filters.scope);
+ search.set('source', filters.source);
+ search.set('state', filters.state);
+ const query = filters.search.trim();
+ if (query) search.set('q', query);
+ if (filters.selectedKey) search.set('plugin', filters.selectedKey);
+ return search;
+}
diff --git a/src/pages/ProjectPlugins/index.tsx b/src/pages/ProjectPlugins/index.tsx
deleted file mode 100644
index a0a8dc6..0000000
--- a/src/pages/ProjectPlugins/index.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { useEffect, useMemo, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { AlertCircle, CheckCircle2, ChevronRight, CircleOff, Plug, RefreshCw, ShieldCheck } from 'lucide-react';
-import { toast } from 'sonner';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import { DataServicePluginSettings } from '@/components/plugins/data-service-settings';
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
-import type { CodingPluginItem, CodingPluginProject, PluginBilling } from '@/lib/coding-plugins';
-import { cn } from '@/lib/utils';
-import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
-import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
-import { useAuthStore } from '@/stores/auth';
-import { isCodeOwnedOptionalBundledPluginId } from '../../../shared/coding-plugins';
-import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
-import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
-import type { DataServiceInstanceState } from '../../../shared/data-service';
-
-const PLUGIN_SETTINGS_SURFACES = { 'data-service': DataServicePluginSettings } as const;
-
-const STATE_TEXT: Record = {
- unavailable: '暂不可用', disabled: '未启用', identity_required: '需要项目 ID',
- authentication_required: '需要登录', configuration_required: '待配置', ready: '已就绪', degraded: '服务降级',
-};
-
-const MUTATION_TEXT = { read: '只读', write: '变更数据', destructive: '破坏性操作' } as const;
-const PERMISSION_TEXT: Record = {
- 'project.data.admin': '管理项目开发数据', 'project.data.configure': '配置项目数据空间',
- 'project.data.read': '读取项目开发数据', 'project.data.write': '写入项目开发数据',
-};
-
-function StateMark({ state }: { state: CodingPluginItem['state'] }) {
- const Icon = state === 'ready' ? CheckCircle2 : state === 'disabled' ? CircleOff : AlertCircle;
- return {STATE_TEXT[state]};
-}
-
-function billingText(billing: PluginBilling): string {
- if (billing.availability === 'unavailable') return '计费策略暂不可用,相关调用已停用。';
- if (billing.mode === 'included') return '当前包含,不按单次调用扣点';
- if (billing.mode === 'external_account') return '由外部服务商计费,不计入 Token Point。';
- const unit = billing.unitName ? `计量单位:${billing.unitName}` : '以服务端计量为准';
- return `平台计量扣点。${unit},价格与回执以服务端为准。`;
-}
-
-type ViewProps = {
- projectName: string;
- projection: CodingPluginProject;
- dataService: DataServiceInstanceState | null;
- pending: Record;
- agentNames: Record;
- refreshError?: string | null;
- marketplaceLibrary?: MarketplaceLibrarySnapshot | null;
- installations?: Record;
- onRefresh(): void | Promise;
- onSetEnabled(pluginId: string, enabled: boolean): void | Promise;
- onConfigure(collections: string[]): void | Promise;
- onReset(): void | Promise;
- onRemoveCollection(collection: string): void | Promise;
- onRemoveProject(): void | Promise;
-};
-
-export function ProjectPluginsView(props: ViewProps) {
- const navigate = useNavigate();
- const [selectedId, setSelectedId] = useState(props.projection.items[0]?.id ?? null);
- const [disableCandidate, setDisableCandidate] = useState(null);
- const selected = props.projection.items.find(({ id }) => id === selectedId) ?? props.projection.items[0] ?? null;
- const SettingsSurface = selected?.settingsSurface && selected.settingsSurface in PLUGIN_SETTINGS_SURFACES
- ? PLUGIN_SETTINGS_SURFACES[selected.settingsSurface as keyof typeof PLUGIN_SETTINGS_SURFACES]
- : null;
- const pendingToggle = selected ? Boolean(props.pending[`enabled:${props.projection.project.localProjectId}:${selected.id}`]) : false;
- const libraryEntry = selected ? props.marketplaceLibrary?.items.find(({ pluginId }) => pluginId === selected.id) : null;
- const installation = selected ? props.installations?.[selected.id] : null;
-
- return (
-
-
- {props.projection.policyStatus === 'stale' ? 正在显示上次可用的插件策略;服务恢复前不作为新的计费依据。
: null}
- {props.refreshError ? 刷新失败,正在保留上次项目插件页面。{props.refreshError}
: null}
-
-
- {props.projection.items.map((plugin) => (
-
- ))}
-
- {selected ? (
-
- {selected.displayName}
{selected.description}
- {selected.enabled ?
:
}
-
- {libraryEntry?.removedAt ? 账号插件库已移除;项目配置仍保留此插件 ID,重新免费获取后可恢复。
: null}
- {libraryEntry?.acquisitionMode === 'user_acquired' && !libraryEntry.removedAt && !installation && !isCodeOwnedOptionalBundledPluginId(selected.id) ? 插件已免费获取但尚未下载到本机,当前项目配置会继续保留。
: null}
-
-
-
- 合同版本
v{selected.contractVersion}
- 后端
{selected.requiresBackend ? '需要' : '不需要'}
-
- {selected.state === 'identity_required' ? : null}
- {selected.state === 'authentication_required' ? : null}
- 能力与权限
- {selected.capabilities.map((capability) =>
{capability.id}
{capability.operations.map((operation) =>
{operation.id}{operation.tool ? <>{MUTATION_TEXT[operation.tool.mutation]}{operation.tool.name}> : SDK}
{operation.tool ? <>
{operation.tool.description}
{operation.tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}
> :
由生成的 Data Service SDK 使用,不作为 Pi 工具暴露。
}
{billingText(operation.billing)}
)}
)}
-
- 伙伴分配
- {selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) =>
{props.agentNames[id] ?? '未知伙伴'}) :
尚未分配给任何伙伴。
}
-
- 用量与计费
各操作的计费模式已在上方能力列表中逐项显示;平台计量操作的流水仍由权益所有者查看。
- {SettingsSurface && selected.enabled ? : null}
-
- ) :
当前没有可用插件。
}
-
- {props.projection.unknownPluginIds.length ? 配置保留、当前不可用
这些插件 ID 仍保存在项目配置中,但当前账号或设备没有可验证的插件定义。不会自动获取、下载、启用或分配。
{props.projection.unknownPluginIds.map((pluginId) => {pluginId} )}
: null}
-
-
- );
-}
-
-export function ProjectPlugins() {
- const user = useAuthStore((state) => state.user);
- const accountKey = user?.userId ?? user?.username ?? null;
- const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
- const config = useCodingWorkspaceStore((state) => state.config);
- const projection = useCodingPluginsStore((state) => state.projection);
- const dataService = useCodingPluginsStore((state) => state.dataService);
- const pending = useCodingPluginsStore((state) => state.pending);
- const loadState = useCodingPluginsStore((state) => state.loadState);
- const error = useCodingPluginsStore((state) => state.error);
- const marketplaceLibrary = usePluginMarketplaceStore((state) => state.library);
- const installations = usePluginMarketplaceStore((state) => state.installations);
- const agentNames = useMemo(() => Object.fromEntries((config?.agents ?? []).map((agent) => [agent.id, agent.name || agent.roleName])), [config]);
- useEffect(() => { if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined); }, [activeProject]);
- useEffect(() => {
- pluginMarketplaceStore.getState().activateAccount(accountKey);
- if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
- }, [accountKey]);
- if (!activeProject) return 请先选择一个项目。
;
- const currentProjection = projection?.project.localProjectId === activeProject.id ? projection : null;
- if (!currentProjection && loadState === 'loading') return 正在读取项目插件…
;
- if (!currentProjection) return 无法读取项目插件。{error ? ` ${error}` : ''}
;
- const safe = (promise: Promise) => promise.catch((reason) => {
- toast.error(reason instanceof Error ? reason.message : String(reason));
- });
- return safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={(pluginId, enabled) => safe(codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled))} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} />;
-}
diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts
index d91fb14..dc35059 100644
--- a/tests/e2e/plugin-marketplace.spec.ts
+++ b/tests/e2e/plugin-marketplace.spec.ts
@@ -1,180 +1,317 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
-test.describe('Plugin Marketplace', () => {
- test('opens the global catalog, searches, and reads detail through fixed Main routes', async ({ launchElectronApp }) => {
+test.describe('Unified plugin workspace', () => {
+ test('acquires an official catalog item into mine without enabling a project', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
- await page.getByTestId('ai-module-option-programming').click();
+ await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
+ const applicationUrl = page.url();
+ await page.goto('about:blank');
await app.evaluate(({ ipcMain }) => {
+ let acquired = false;
const requests: string[] = [];
- (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E = { requests };
- ipcMain.removeHandler('hostapi:fetch');
- ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => {
- const requestPath = request.path ?? '';
- requests.push(requestPath);
- const item = {
- pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感。', category: '效率', tags: ['笔记'],
- providerDisplayName: 'MakeLore', runtimeKind: 'skill_only', runtimeStatus: 'enabled', acquisition: 'free',
- usageBilling: 'included', includedOperationCount: 1, meteredOperationCount: 0, stableVersion: '1.0.0', betaVersion: null,
- };
- if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) {
- return { ok: true, data: { status: 200, ok: true, json: {
- items: [item], nextCursor: null, total: 1, catalogGeneration: 1,
- etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1,
- } } };
- }
- if (requestPath === '/api/coding/plugin-marketplace/plugins/makelore.notes') {
- return { ok: true, data: { status: 200, ok: true, json: {
- ...item, descriptionMarkdown: '详细介绍', permissions: ['读取项目名称'], operations: [],
- stableRelease: { releaseId: 'release-1', pluginId: 'makelore.notes', version: '1.0.0' }, betaRelease: null,
- etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1,
- } } };
- }
- return { ok: false, error: { message: `Unexpected Host API request: ${requestPath}` } };
- });
- });
-
- await page.getByTestId('sidebar-nav-plugin-marketplace').click();
- await expect(page.getByTestId('plugin-marketplace-page')).toBeVisible();
- await expect(page.getByRole('heading', { name: '灵感笔记' })).toBeVisible();
- await page.getByRole('searchbox', { name: '搜索插件' }).fill('笔记');
- await page.getByRole('button', { name: '搜索', exact: true }).click();
- await page.getByRole('button', { name: '查看详情' }).click();
- await expect(page.getByRole('heading', { name: '灵感笔记' }).last()).toBeVisible();
- await expect(page.getByText('详细介绍')).toBeVisible();
-
- const requests = await app.evaluate(() => (
- (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []
- ));
- expect(requests).toContain('/api/coding/plugin-marketplace/catalog?limit=24');
- expect(requests).toContain('/api/coding/plugin-marketplace/catalog?query=%E7%AC%94%E8%AE%B0&limit=24');
- expect(requests).toContain('/api/coding/plugin-marketplace/plugins/makelore.notes');
- const marketplaceRequests = requests.filter((requestPath) => requestPath.startsWith('/api/coding/plugin-marketplace/'));
- expect(JSON.stringify(marketplaceRequests)).not.toMatch(/account|admission|release_id|path=/i);
- } finally {
- await closeElectronApp(app);
- }
- });
-
- test('keeps Beta explicit and exposes bounded unavailable, disabled, and device-delete states', async ({ launchElectronApp }) => {
- const app = await launchElectronApp({ skipSetup: true });
- try {
- const page = await getStableWindow(app);
- await expect(page.getByTestId('ai-module-option-programming')).toBeVisible();
- await app.evaluate(({ ipcMain }) => {
- let installedChannel: 'stable' | 'beta' | null = 'stable';
- const installedVersion = '1.0.0';
- const requests: string[] = [];
- const library = {
- items: [
- {
- pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感。', category: '效率',
- acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
- acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: '2.0.0-beta.1',
- },
- {
- pluginId: 'makelore.paused', title: '暂停插件', summary: '暂时暂停。', category: '效率',
- acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'suspended',
- acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
- },
- {
- pluginId: 'makelore.unavailable', title: '不可用插件', summary: '服务不可用。', category: '效率',
- acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
- acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
- },
- ],
- total: 3,
- stale: false,
- fetchedAt: 1,
+ const item = {
+ pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感。', category: '效率', tags: ['笔记'],
+ providerDisplayName: 'MakeLore', runtimeKind: 'skill_only', runtimeStatus: 'enabled', acquisition: 'free',
+ usageBilling: 'included', includedOperationCount: 1, meteredOperationCount: 0,
+ stableVersion: '1.0.0', betaVersion: null,
};
- const projection = () => ({
- library,
- installations: [
- ...(installedChannel ? [{
- status: 'installed', pluginId: 'makelore.notes', releaseId: installedChannel === 'beta' ? 'beta-2' : 'stable-1',
- version: installedChannel === 'beta' ? '2.0.0-beta.1' : installedVersion, channel: installedChannel,
- }] : []),
- { status: 'unavailable', pluginId: 'makelore.unavailable', reason: 'plugin_backend_unavailable' },
- ],
- });
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
- (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E = { requests };
+ const library = () => ({
+ library: {
+ items: acquired ? [{
+ pluginId: item.pluginId, title: item.title, summary: item.summary, category: item.category,
+ acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
+ acquiredAt: '2026-09-03T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
+ }] : [],
+ total: acquired ? 1 : 0, stale: false, fetchedAt: 1,
+ },
+ installations: [],
+ });
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E = { requests };
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
const requestPath = request.path ?? '';
const method = (request.method ?? 'GET').toUpperCase();
requests.push(`${method} ${requestPath}`);
- if (requestPath === '/api/auth/session/sync') {
- const response = result({
- success: true,
- session: {
- accessToken: 'marketplace-e2e-token', tokenType: 'Bearer',
- expiresAt: Date.now() + 3_600_000, lastActiveAt: Date.now(), canRefresh: false,
- },
- });
- return response;
+ if (requestPath === '/api/auth/session/sync') return result({
+ success: true,
+ session: {
+ accessToken: 'plugins-e2e-token', tokenType: 'Bearer', expiresAt: Date.now() + 3_600_000,
+ lastActiveAt: Date.now(), canRefresh: false,
+ },
+ });
+ if (requestPath === '/api/auth/me') return result({
+ success: true,
+ user: {
+ username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [],
+ },
+ moduleAccess: { programming: true, design: true, learning: true, robot: true },
+ });
+ if (requestPath === '/api/works/user/agent-profile') return result({
+ success: true,
+ profile: {
+ display_name: 'E2E 插件用户', age: null, gender: null, avatar_url: null,
+ share_age_with_agents: false, share_gender_with_agents: false,
+ analysis_enabled: true, completed: true, version: 1,
+ updated_at: '2026-09-03T00:00:00.000Z',
+ },
+ });
+ if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({
+ items: [item], nextCursor: null, total: 1, catalogGeneration: 1,
+ etag: 'plugins-1', pricingVersionId: null, stale: false, fetchedAt: 1,
+ });
+ if (requestPath === '/api/coding/plugin-marketplace/plugins/makelore.notes') return result({
+ ...item,
+ descriptionMarkdown: '详细介绍', permissions: ['读取项目名称'], operations: [],
+ stableRelease: { releaseId: 'notes-1', version: '1.0.0' }, betaRelease: null,
+ etag: 'plugins-1', pricingVersionId: null, stale: false, fetchedAt: 1,
+ });
+ if (requestPath === '/api/coding/plugin-marketplace/library' && method === 'GET') return result(library());
+ if (requestPath === '/api/coding/plugin-marketplace/library/makelore.notes' && method === 'PUT') {
+ acquired = true;
+ return result(library());
}
- if (requestPath === '/api/auth/me') return result({ success: true, moduleAccess: { programming: true } });
- if (requestPath === '/api/coding/plugin-marketplace/library') return result(projection());
- if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes/beta' && method === 'POST') {
- installedChannel = 'beta';
- return result({ status: 'installed', pluginId: 'makelore.notes', releaseId: 'beta-2', version: '2.0.0-beta.1', channel: 'beta' });
- }
- if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes' && method === 'DELETE') {
- installedChannel = null;
- return result({ status: 'removed', pluginId: 'makelore.notes', releaseId: 'beta-2', version: '2.0.0-beta.1', channel: 'beta' });
+ if (requestPath === '/api/coding/device-packages') {
+ return result({ schemaVersion: 1, generation: 1, packages: [] });
}
return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } };
});
});
- await page.addInitScript(({ key, value }) => {
- localStorage.setItem(key, value);
- }, {
- key: 'niancode-auth',
- value: JSON.stringify({
- state: {
- authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'marketplace-e2e-token',
- tokenType: 'Bearer', expiresAt: Date.now() + 3_600_000, lastActiveAt: Date.now(), canRefresh: false,
- legacyRefreshToken: null,
- user: { username: 'marketplace-e2e', userId: 'marketplace-e2e-user', tenantId: null, deptId: null, authorities: [] },
- moduleAccess: { programming: true, design: true, learning: true, robot: true },
- },
- version: 2,
- }),
- });
- await page.reload({ waitUntil: 'domcontentloaded' });
- await expect(page.getByTestId('ai-module-option-programming')).toBeVisible();
- const programmingOption = page.getByTestId('ai-module-option-programming');
- await programmingOption.click();
- await expect(page.getByTestId('main-layout')).toBeVisible();
- await page.keyboard.press('Escape');
- await page.getByTestId('sidebar-nav-my-plugins').click();
- await expect(page.getByTestId('my-plugins-page')).toBeVisible();
+ await page.goto(applicationUrl, { waitUntil: 'domcontentloaded' });
await expect.poll(async () => app.evaluate(() => (
- ((globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? [])
- .filter((requestPath) => requestPath.includes('/api/auth') || requestPath.includes('/plugin-marketplace'))
- ))).toEqual(expect.arrayContaining([
- 'GET /api/auth/me',
- 'GET /api/coding/plugin-marketplace/library',
- ]));
- await expect(page.getByRole('heading', { name: '暂停插件' })).toBeVisible();
- await expect(page.getByText('运行已暂停')).toBeVisible();
- await expect(page.getByRole('button', { name: '下载暂停插件' })).toBeDisabled();
- await expect(page.getByText('Marketplace 服务暂不可用,设备版本未替换。')).toBeVisible();
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests
+ .includes('GET /api/auth/me') ?? false
+ ))).toBe(true);
+ await page.getByTestId('ai-module-option-programming').click();
+ await page.keyboard.press('Escape');
+ await page.getByTestId('sidebar-nav-plugins').click();
- await page.getByRole('button', { name: '安装 Beta灵感笔记' }).click();
- await expect(page.getByText('当前频道:Beta')).toBeVisible();
- await expect(page.getByRole('button', { name: '安装 Beta灵感笔记' })).toHaveCount(0);
- await page.getByRole('button', { name: '删除设备上的灵感笔记' }).click();
- const notesCard = page.locator('article').filter({ has: page.getByRole('heading', { name: '灵感笔记' }) });
- await expect(notesCard.getByText('尚未下载到设备')).toBeVisible();
- await expect(notesCard.getByRole('button', { name: '下载灵感笔记' })).toBeVisible();
+ await expect(page.getByTestId('plugins-page')).toBeVisible();
+ await expect(page).toHaveURL(/\/plugins\?scope=all/);
+ await expect(page.getByTestId('plugins-page').getByText('尚未获取')).toBeVisible();
+ await page.getByRole('button', { name: '查看灵感笔记详情' }).click();
+ await expect(page.getByRole('dialog', { name: '灵感笔记' })).toContainText('详细介绍');
+ await page.getByRole('button', { name: '免费获取灵感笔记' }).click();
+ await expect(page.getByRole('button', { name: '从账号移除灵感笔记' })).toBeVisible();
+
+ await page.getByRole('button', { name: '关闭插件详情' }).click();
+ await page.getByRole('combobox', { name: '状态', exact: true }).selectOption('mine');
+ await expect(page).toHaveURL(/state=mine/);
+ await expect(page.getByRole('heading', { name: '灵感笔记' })).toBeVisible();
+ await expect(page.getByText('不适用项目启用')).toBeVisible();
const requests = await app.evaluate(() => (
- (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests ?? []
));
- expect(requests).toContain('POST /api/coding/plugin-marketplace/install/makelore.notes/beta');
- expect(requests).toContain('DELETE /api/coding/plugin-marketplace/install/makelore.notes');
+ expect(requests).toContain('PUT /api/coding/plugin-marketplace/library/makelore.notes');
+ expect(requests.some((request) => request.startsWith('PUT /api/coding/plugins/'))).toBe(false);
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+
+ test('keeps a local package visible and toggleable when the catalog fails', async ({ launchElectronApp }) => {
+ const app = await launchElectronApp({ skipSetup: true });
+ try {
+ const page = await getStableWindow(app);
+ await app.evaluate(({ ipcMain }) => {
+ let enabled = true;
+ const requests: string[] = [];
+ const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
+ const index = () => ({
+ schemaVersion: 1,
+ generation: enabled ? 1 : 2,
+ packages: [{
+ schemaVersion: 1, packageId: 'local-search', displayName: 'Local Search', resolvedVersion: '1.2.3',
+ source: { kind: 'npm', requested: 'local-search', resolved: 'local-search@1.2.3' },
+ kind: 'mixed', skillEntries: [{ id: 'search', entryPath: 'skills/search/SKILL.md' }],
+ extensionEntries: ['extensions/search.js'], enabled, confirmedExecutableCode: true,
+ installedAt: '2026-09-03T00:00:00Z',
+ }],
+ });
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E = { requests };
+ ipcMain.removeHandler('hostapi:fetch');
+ ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
+ const requestPath = request.path ?? '';
+ const method = (request.method ?? 'GET').toUpperCase();
+ requests.push(`${method} ${requestPath}`);
+ if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) {
+ return { ok: false, error: { message: 'catalog offline' } };
+ }
+ if (requestPath === '/api/coding/device-packages' && method === 'GET') return result(index());
+ if (requestPath === '/api/coding/device-packages/local-search' && method === 'PATCH') {
+ enabled = Boolean(JSON.parse(request.body ?? '{}').enabled);
+ return result(index());
+ }
+ return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } };
+ });
+ });
+ await page.getByTestId('ai-module-option-programming').click();
+ await page.getByTestId('sidebar-nav-plugins').click();
+
+ await expect(page.getByTestId('plugins-page')).toBeVisible();
+ await expect(page.getByRole('alert')).toContainText('官方目录刷新失败');
+ await expect(page.getByRole('heading', { name: 'Local Search' })).toBeVisible();
+ await expect(page.getByText('本机全局已启用')).toBeVisible();
+ await expect(page.getByText(/仅通过对话安装/)).toBeVisible();
+ await expect(page.getByRole('textbox', { name: /包|路径|source/i })).toHaveCount(0);
+ await page.getByRole('button', { name: '查看Local Search详情' }).click();
+ await expect(page.getByText(/完整桌面权限/)).toBeVisible();
+ await page.getByRole('button', { name: '本机全局停用Local Search' }).click();
+ await expect(page.getByRole('button', { name: '本机全局启用Local Search' })).toBeVisible();
+ await page.getByRole('button', { name: '本机全局启用Local Search' }).click();
+ await expect(page.getByRole('button', { name: '本机全局停用Local Search' })).toBeVisible();
+
+ const requests = await app.evaluate(() => (
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests ?? []
+ ));
+ expect(requests.filter((request) => request === 'PATCH /api/coding/device-packages/local-search')).toHaveLength(2);
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+
+ test('keeps system, bundled, and local actions on their own authorities', async ({ launchElectronApp }) => {
+ const app = await launchElectronApp({ skipSetup: true });
+ try {
+ const page = await getStableWindow(app);
+ await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
+ const applicationUrl = page.url();
+ await page.goto('about:blank');
+ await app.evaluate(({ ipcMain }) => {
+ let localEnabled = true;
+ let bundledRemoved = false;
+ const requests: string[] = [];
+ const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
+ const catalogItems = [
+ {
+ pluginId: 'makelore.data-service', title: '开发数据服务', summary: '系统数据能力。', category: '系统', tags: ['data'],
+ providerDisplayName: 'MakeLore', runtimeKind: 'bundled_typed', runtimeStatus: 'enabled', acquisition: 'system_included',
+ usageBilling: 'included', includedOperationCount: 1, meteredOperationCount: 0, stableVersion: '1.0.0', betaVersion: null,
+ },
+ {
+ pluginId: 'makelore.game-resource', title: '游戏资源生成', summary: '生成游戏资源。', category: '创作', tags: ['game'],
+ providerDisplayName: 'MakeLore', runtimeKind: 'bundled_typed', runtimeStatus: 'enabled', acquisition: 'free',
+ usageBilling: 'token_point', includedOperationCount: 0, meteredOperationCount: 1, stableVersion: '1.0.0', betaVersion: null,
+ },
+ ];
+ const library = () => ({
+ library: {
+ items: [
+ {
+ pluginId: 'makelore.data-service', title: '开发数据服务', summary: '系统数据能力。', category: '系统',
+ acquisition: 'system_included', acquisitionMode: 'system_included', catalogStatus: 'active', runtimeStatus: 'enabled',
+ acquiredAt: null, removedAt: null, stableVersion: '1.0.0', betaVersion: null,
+ },
+ {
+ pluginId: 'makelore.game-resource', title: '游戏资源生成', summary: '生成游戏资源。', category: '创作',
+ acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
+ acquiredAt: '2026-09-03T00:00:00Z', removedAt: bundledRemoved ? '2026-09-03T01:00:00Z' : null,
+ stableVersion: '1.0.0', betaVersion: null,
+ },
+ ],
+ total: 2, stale: false, fetchedAt: 1,
+ },
+ installations: [],
+ });
+ const device = () => ({
+ schemaVersion: 1, generation: localEnabled ? 1 : 2,
+ packages: [{
+ schemaVersion: 1, packageId: 'local-tool', displayName: 'Local Tool', resolvedVersion: '1.0.0',
+ source: { kind: 'npm', requested: 'local-tool', resolved: 'local-tool@1.0.0' },
+ kind: 'skill-only', skillEntries: [{ id: 'local-tool', entryPath: 'SKILL.md' }], extensionEntries: [],
+ enabled: localEnabled, confirmedExecutableCode: false, installedAt: '2026-09-03T00:00:00Z',
+ }],
+ });
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E = { requests };
+ ipcMain.removeHandler('hostapi:fetch');
+ ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
+ const requestPath = request.path ?? '';
+ const method = (request.method ?? 'GET').toUpperCase();
+ requests.push(`${method} ${requestPath}`);
+ if (requestPath === '/api/auth/session/sync') return result({ success: true, session: {
+ accessToken: 'plugins-e2e-token', tokenType: 'Bearer', expiresAt: Date.now() + 3_600_000,
+ lastActiveAt: Date.now(), canRefresh: false,
+ } });
+ if (requestPath === '/api/auth/me') return result({
+ success: true,
+ user: {
+ username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [],
+ },
+ moduleAccess: { programming: true, design: true, learning: true, robot: true },
+ });
+ if (requestPath === '/api/works/user/agent-profile') return result({
+ success: true,
+ profile: {
+ display_name: 'E2E 插件用户', age: null, gender: null, avatar_url: null,
+ share_age_with_agents: false, share_gender_with_agents: false,
+ analysis_enabled: true, completed: true, version: 1,
+ updated_at: '2026-09-03T00:00:00.000Z',
+ },
+ });
+ if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({
+ items: catalogItems, nextCursor: null, total: 2, catalogGeneration: 1,
+ etag: 'plugins-1', pricingVersionId: null, stale: false, fetchedAt: 1,
+ });
+ if (requestPath === '/api/coding/plugin-marketplace/library' && method === 'GET') return result(library());
+ if (requestPath === '/api/coding/plugin-marketplace/library/makelore.game-resource' && method === 'DELETE') {
+ bundledRemoved = true;
+ return result(library());
+ }
+ if (requestPath === '/api/coding/device-packages' && method === 'GET') return result(device());
+ if (requestPath === '/api/coding/device-packages/local-tool' && method === 'PATCH') {
+ localEnabled = Boolean(JSON.parse(request.body ?? '{}').enabled);
+ return result(device());
+ }
+ return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } };
+ });
+ });
+ await page.goto(applicationUrl, { waitUntil: 'domcontentloaded' });
+ await expect.poll(async () => app.evaluate(() => (
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests
+ .includes('GET /api/auth/me') ?? false
+ ))).toBe(true);
+ await page.getByTestId('ai-module-option-programming').click();
+ await page.keyboard.press('Escape');
+ await page.getByTestId('sidebar-nav-plugins').click();
+ await expect(page.getByTestId('plugins-page')).toBeVisible();
+ await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0);
+ await expect(page.getByTestId('sidebar-nav-my-plugins')).toHaveCount(0);
+ await expect(page.getByTestId('sidebar-nav-project-plugins')).toHaveCount(0);
+ await expect.poll(async () => app.evaluate(() => (
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests ?? []
+ ))).toContain('GET /api/coding/plugin-marketplace/library');
+ const bundledCard = page.getByRole('heading', { name: '游戏资源生成' }).locator('..');
+ await expect(bundledCard).toContainText('账号已获取');
+
+ await page.getByRole('button', { name: '查看开发数据服务详情' }).click();
+ await expect(page.getByText('当前状态没有可执行操作。')).toBeVisible();
+ await page.getByRole('button', { name: '关闭插件详情' }).click();
+
+ await page.getByRole('button', { name: '查看游戏资源生成详情' }).click();
+ await expect(page.getByRole('button', { name: '从账号移除游戏资源生成' })).toBeVisible();
+ await expect(page.getByRole('button', { name: /下载.*游戏资源生成|删除.*游戏资源生成.*官方设备包/ })).toHaveCount(0);
+ await page.getByRole('button', { name: '从账号移除游戏资源生成' }).click();
+ await page.getByRole('button', { name: '确认从账号移除' }).click();
+ await page.getByRole('button', { name: '关闭插件详情' }).click();
+
+ await page.getByRole('button', { name: '查看Local Tool详情' }).click();
+ await expect(page.getByRole('button', { name: '本机全局停用Local Tool' })).toBeVisible();
+ await expect(page.getByRole('button', { name: /账号.*Local Tool|下载.*Local Tool/ })).toHaveCount(0);
+ await page.getByRole('button', { name: '本机全局停用Local Tool' }).click();
+
+ const requests = await app.evaluate(() => (
+ (globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests ?? []
+ ));
+ const mutations = requests.filter((request) => (
+ request.includes(' /api/coding/')
+ && (request.startsWith('PATCH ') || request.startsWith('PUT ') || request.startsWith('DELETE ') || request.startsWith('POST '))
+ ));
+ expect(mutations).toEqual([
+ 'DELETE /api/coding/plugin-marketplace/library/makelore.game-resource',
+ 'PATCH /api/coding/device-packages/local-tool',
+ ]);
} finally {
await closeElectronApp(app);
}
diff --git a/tests/e2e/project-plugins.spec.ts b/tests/e2e/project-plugins.spec.ts
index 1f8d233..f9c9765 100644
--- a/tests/e2e/project-plugins.spec.ts
+++ b/tests/e2e/project-plugins.spec.ts
@@ -3,8 +3,8 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
-test.describe('Project Plugin Center', () => {
- test('enables selection without configuring Data Service or duplicating project IDs', async ({ launchElectronApp }) => {
+test.describe('Unified project plugin workspace', () => {
+ test('enables and disables a project plugin without configuring it, then opens Agent assignment', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-plugins-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
try {
@@ -20,53 +20,109 @@ test.describe('Project Plugin Center', () => {
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await app.evaluate(({ ipcMain }) => {
- const state = { enabled: false, dataServiceCalls: 0, localProjectId: '' };
+ const state = {
+ enabled: false,
+ dataServiceCalls: 0,
+ localProjectId: '',
+ mutations: [] as Array<{ enabled: boolean; projectId: string }>,
+ };
+ const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
+ const project = () => ({
+ schemaVersion: 1,
+ project: {
+ localProjectId: state.localProjectId,
+ durableProjectId: '11111111-1111-4111-8111-111111111111',
+ },
+ policyStatus: 'current',
+ unknownPluginIds: [],
+ items: [{
+ id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务',
+ description: '为当前项目提供数据。', contractVersion: 1, requiresBackend: true,
+ enabled: state.enabled,
+ state: state.enabled ? 'configuration_required' : 'disabled',
+ backend: { status: 'unconfigured' },
+ skills: [{ id: 'data-service', assignedAgentIds: [] }],
+ capabilities: [{
+ id: 'data-service.control',
+ operations: [{
+ id: 'inspect',
+ billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' },
+ tool: null,
+ }],
+ }],
+ settingsSurface: 'data-service',
+ }],
+ });
(globalThis as typeof globalThis & { __projectPluginE2E?: typeof state }).__projectPluginE2E = state;
ipcMain.removeHandler('hostapi:fetch');
- ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
+ ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
const requestPath = request.path ?? '';
+ const method = (request.method ?? 'GET').toUpperCase();
if (requestPath.startsWith('/api/works/data-service/')) {
state.dataServiceCalls += 1;
return { ok: false, error: { message: 'Enable must not configure Data Service' } };
}
- if (requestPath.startsWith('/api/coding/plugins')) {
- if (request.method === 'PUT') state.enabled = true;
- else state.localProjectId = new URL(requestPath, 'https://makelore.local').searchParams.get('projectId') ?? '';
- return { ok: true, data: { status: 200, ok: true, json: {
- schemaVersion: 1,
- project: { localProjectId: state.localProjectId, durableProjectId: '11111111-1111-4111-8111-111111111111' },
- policyStatus: 'current',
- unknownPluginIds: [],
- items: [{
- id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供数据。',
- contractVersion: 1, requiresBackend: true,
- enabled: state.enabled, state: state.enabled ? 'configuration_required' : 'disabled', backend: { status: 'unconfigured' },
- skills: [{ id: 'data-service', assignedAgentIds: [] }],
- capabilities: [{ id: 'data-service.control', operations: [{ id: 'inspect', billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' }, tool: null }] }],
- settingsSurface: 'data-service',
- }],
- } } };
+ if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({
+ items: [{
+ pluginId: 'makelore.data-service', title: '开发数据服务', summary: '为当前项目提供数据。',
+ category: '系统', tags: ['data'], providerDisplayName: 'MakeLore', runtimeKind: 'bundled_typed',
+ runtimeStatus: 'enabled', acquisition: 'system_included', usageBilling: 'included',
+ includedOperationCount: 1, meteredOperationCount: 0, stableVersion: '1.0.0', betaVersion: null,
+ }],
+ nextCursor: null, total: 1, catalogGeneration: 1, etag: 'plugins-1',
+ pricingVersionId: null, stale: false, fetchedAt: 1,
+ });
+ if (requestPath === '/api/coding/device-packages') {
+ return result({ schemaVersion: 1, generation: 1, packages: [] });
}
- return { ok: false, error: { message: `Unexpected Host API request: ${requestPath}` } };
+ if (requestPath.startsWith('/api/coding/plugins')) {
+ if (method === 'PUT') {
+ const body = JSON.parse(request.body ?? '{}') as { enabled?: unknown; projectId?: unknown };
+ state.enabled = body.enabled === true;
+ state.mutations.push({ enabled: state.enabled, projectId: String(body.projectId ?? '') });
+ } else {
+ state.localProjectId = new URL(requestPath, 'https://makelore.local').searchParams.get('projectId') ?? '';
+ }
+ return result(project());
+ }
+ return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } };
});
});
- await page.getByTestId('sidebar-nav-project-plugins').click();
- await expect(page.getByTestId('project-plugins-page')).toBeVisible();
- await expect(page.getByText('当前包含,不按单次调用扣点')).toBeVisible();
+ await page.getByTestId('sidebar-nav-plugins').click();
+ await expect(page.getByTestId('plugins-page')).toBeVisible();
+ await expect(page).toHaveURL(/\/plugins\?scope=project/);
+ await page.getByRole('button', { name: '查看开发数据服务详情' }).click();
+ await expect(page.getByText('按平台包含,不按单次插件调用扣点。')).toBeVisible();
const localProjectId = await app.evaluate(() => (
(globalThis as typeof globalThis & { __projectPluginE2E?: { localProjectId: string } }).__projectPluginE2E?.localProjectId
));
expect(localProjectId).toBeTruthy();
await expect(page.getByText(localProjectId as string)).toHaveCount(0);
await expect(page.getByText('11111111-1111-4111-8111-111111111111')).toHaveCount(0);
- await page.getByRole('button', { name: '启用到项目开发数据服务' }).click();
- await expect(page.getByRole('article').getByText('待配置')).toBeVisible();
+
+ await page.getByRole('button', { name: '启用开发数据服务到当前项目' }).click();
+ await expect(page.getByRole('button', { name: '从当前项目禁用开发数据服务' })).toBeVisible();
+ await expect(page.getByText('插件专属设置')).toBeVisible();
await expect(page.getByRole('button', { name: '创建开发数据空间' })).toBeDisabled();
- const dataServiceCalls = await app.evaluate(() => (
- (globalThis as typeof globalThis & { __projectPluginE2E?: { dataServiceCalls: number } }).__projectPluginE2E?.dataServiceCalls
+
+ await page.getByRole('button', { name: '从当前项目禁用开发数据服务' }).click();
+ await expect(page.getByRole('alertdialog')).toContainText('从当前项目禁用开发数据服务');
+ await page.getByRole('button', { name: '确认从当前项目禁用' }).click();
+ await expect(page.getByRole('button', { name: '启用开发数据服务到当前项目' })).toBeVisible();
+
+ await page.getByRole('button', { name: '分配开发数据服务给伙伴' }).click();
+ await expect(page.getByTestId('project-configuration-page')).toBeVisible();
+ const state = await app.evaluate(() => (
+ (globalThis as typeof globalThis & {
+ __projectPluginE2E?: { dataServiceCalls: number; mutations: Array<{ enabled: boolean; projectId: string }> };
+ }).__projectPluginE2E
));
- expect(dataServiceCalls).toBe(0);
+ expect(state?.dataServiceCalls).toBe(0);
+ expect(state?.mutations).toEqual([
+ { enabled: true, projectId: localProjectId },
+ { enabled: false, projectId: localProjectId },
+ ]);
} finally {
await closeElectronApp(app);
await rm(projectPath, { recursive: true, force: true });
diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx
index 1cccf08..827d9c7 100644
--- a/tests/unit/main-layout-module-gate.test.tsx
+++ b/tests/unit/main-layout-module-gate.test.tsx
@@ -105,22 +105,13 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});
- it('allows the project Plugin Center to resolve identity before initialization', () => {
- renderLayout('/project-plugins');
+ it('keeps the canonical plugin workspace outside the local project initialization gate', () => {
+ renderLayout('/plugins');
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
});
- it.each(['/plugin-marketplace', '/my-plugins'])(
- 'keeps global plugin route %s outside the local project initialization gate',
- (path) => {
- renderLayout(path);
- expect(screen.getByTestId('route-content')).toBeVisible();
- expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
- },
- );
-
it('keeps the programming workspace padding compensation separate from painting', () => {
renderLayout('/chat');
diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts
index 8a1eb5e..2d5a24d 100644
--- a/tests/unit/pi-product-artifact.test.ts
+++ b/tests/unit/pi-product-artifact.test.ts
@@ -36,7 +36,7 @@ const MARKETPLACE_ARTIFACT_TEXT = [
'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/',
'effectiveSkillIds', 'pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library',
- '免费获取', '我的插件',
+ '免费获取', '获取、项目启用与伙伴分配彼此独立', '/plugins',
].join('\n');
async function createAsarFixture(source: string, archive: string) {
@@ -389,7 +389,7 @@ describe('final Pi product artifact verification', () => {
'makelore-device-package.v1 /api/coding/device-packages device-parent-workers',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
- '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件',
+ '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 获取、项目启用与伙伴分配彼此独立 /plugins',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createAsarFixture(source, appAsar);
@@ -407,7 +407,7 @@ describe('final Pi product artifact verification', () => {
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
- '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件',
+ '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件 /plugins',
].join('\n'));
const staleAsar = path.join(root, 'stale.asar');
await createAsarFixture(staleSource, staleAsar);
diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx
deleted file mode 100644
index b22322b..0000000
--- a/tests/unit/plugin-marketplace-pages.test.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import { describe, expect, it, vi } from 'vitest';
-import { PluginMarketplaceView } from '@/pages/PluginMarketplace';
-import { MyPluginsView } from '@/pages/MyPlugins';
-import type {
- MarketplaceCatalogPage,
- MarketplaceLibrarySnapshot,
- MarketplacePluginDetail,
-} from '@/lib/plugin-marketplace';
-
-const item = {
- pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感与任务。',
- category: '效率', tags: ['笔记'], providerDisplayName: 'MakeLore',
- runtimeKind: 'skill_only' as const, runtimeStatus: 'enabled' as const,
- acquisition: 'free' as const, usageBilling: 'mixed' as const,
- includedOperationCount: 2, meteredOperationCount: 1,
- stableVersion: '2.0.0', betaVersion: null,
-};
-const catalog: MarketplaceCatalogPage = {
- items: [item], nextCursor: null, total: 1, catalogGeneration: 2,
- etag: '"plugins-2-tp-1"', pricingVersionId: '1', stale: false, fetchedAt: 1,
-};
-const detail: MarketplacePluginDetail = {
- ...item, descriptionMarkdown: '详细介绍', permissions: ['读取项目名称'], operations: [],
- stableRelease: { releaseId: 'release-2', version: '2.0.0' }, betaRelease: null,
- etag: '"plugins-2-tp-1"', pricingVersionId: '1', stale: false, fetchedAt: 1,
-};
-
-describe('Plugin Marketplace pages', () => {
- it('supports search/detail and keeps free acquisition separate', () => {
- const onSearch = vi.fn();
- const onAcquire = vi.fn();
- render();
-
- expect(screen.getByRole('heading', { name: '插件中心' })).toHaveClass('text-balance');
- expect(screen.getByText('部分能力包含,部分按 Token Point 用量计费')).toBeVisible();
- expect(screen.getByText('1')).toHaveClass('tabular-nums');
- fireEvent.change(screen.getByRole('searchbox', { name: '搜索插件' }), { target: { value: '笔记' } });
- fireEvent.submit(screen.getByRole('search'));
- expect(onSearch).toHaveBeenCalledWith(expect.objectContaining({ query: '笔记' }));
- const acquire = screen.getByRole('button', { name: '免费获取灵感笔记' });
- expect(acquire).toHaveClass('min-h-10');
- fireEvent.click(acquire);
- expect(onAcquire).toHaveBeenCalledWith('makelore.notes');
- });
-
- it('renders the catalog first-failure and stale states without invented cards', () => {
- const { rerender } = render();
- expect(screen.getByRole('alert')).toHaveTextContent('插件目录暂不可用');
- expect(screen.queryByRole('article')).not.toBeInTheDocument();
-
- rerender();
- expect(screen.getByRole('status')).toHaveTextContent('缓存目录');
- });
-});
-
-describe('My Plugins', () => {
- const library: MarketplaceLibrarySnapshot = {
- total: 4, stale: false, fetchedAt: 1,
- items: [
- { pluginId: 'makelore.notes', title: '灵感笔记', summary: 'Notes', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '2.0.0', betaVersion: '2.1.0-beta.1' },
- { pluginId: 'makelore.removed', title: '旧插件', summary: 'Removed', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-27T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null },
- { pluginId: 'makelore.retired', title: '已退役插件', summary: 'Retired', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'retired', runtimeStatus: 'enabled', acquiredAt: '2026-08-26T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null },
- { pluginId: 'makelore.system', title: '开发数据服务', summary: 'Data', category: '系统', acquisition: 'system_included', acquisitionMode: 'system_included', catalogStatus: 'active', runtimeStatus: 'suspended', acquiredAt: null, removedAt: null, stableVersion: '1.0.0', betaVersion: null },
- ],
- };
-
- it('separates official and device-installed packages without a visible install picker', () => {
- const setEnabled = vi.fn();
- const uninstall = vi.fn();
- render();
-
- expect(screen.getByRole('heading', { name: '官方插件' })).toBeVisible();
- expect(screen.getByRole('heading', { name: '本机安装' })).toBeVisible();
- expect(screen.getByText('Pi Web Search')).toBeVisible();
- expect(screen.getByText(/完整桌面权限/)).toBeVisible();
- expect(screen.queryByRole('textbox', { name: /包|路径|source/i })).not.toBeInTheDocument();
- expect(screen.getByText(/在对话中/)).toBeVisible();
- fireEvent.click(screen.getByRole('button', { name: '停用 Pi Web Search' }));
- fireEvent.click(screen.getByRole('button', { name: '移除本机包 Pi Web Search' }));
- expect(setEnabled).toHaveBeenCalledWith('pi-web-search', false);
- expect(uninstall).toHaveBeenCalledWith('pi-web-search');
- });
-
- it('shows update/remove/reacquire and project/partner actions without chaining them', () => {
- const onUpdate = vi.fn();
- const onRemove = vi.fn();
- const onReacquire = vi.fn();
- render();
-
- fireEvent.click(screen.getByRole('button', { name: '更新灵感笔记' }));
- expect(onUpdate).toHaveBeenCalledWith('makelore.notes');
- expect(onRemove).not.toHaveBeenCalled();
- fireEvent.click(screen.getByRole('button', { name: '移除灵感笔记' }));
- expect(onRemove).toHaveBeenCalledWith('makelore.notes');
- fireEvent.click(screen.getByRole('button', { name: '重新免费获取旧插件' }));
- expect(onReacquire).toHaveBeenCalledWith('makelore.removed');
- expect(screen.getAllByRole('link', { name: '启用到项目' }).length).toBeGreaterThan(0);
- expect(screen.getAllByRole('link', { name: '分配给伙伴' }).length).toBeGreaterThan(0);
- expect(screen.getByText('运行已暂停')).toBeVisible();
- expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible();
- });
-
- it('offers an explicit Beta action without conflating it with stable updates', () => {
- const onInstallBeta = vi.fn();
- render();
- const beta = screen.getByRole('button', { name: '安装 Beta灵感笔记' });
- fireEvent.click(beta);
- expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes');
- expect(screen.getByText('稳定版 2.0.0')).toBeVisible();
- });
-
- it('shows system-included plugins as supplied with MakeLore without device package actions', () => {
- const systemIncluded = library.items[3]!;
- render();
-
- expect(screen.getByText('随 MakeLore 提供')).toBeVisible();
- expect(screen.queryByText('尚未下载到设备')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: '下载开发数据服务' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: '更新开发数据服务' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: '删除设备上的开发数据服务' })).not.toBeInTheDocument();
- expect(screen.getByRole('link', { name: '启用到项目' })).toBeVisible();
- expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible();
- });
-
- it('shows acquired code-owned bundled Plugins as supplied without device download actions', () => {
- const bundled = {
- ...library.items[0],
- pluginId: 'makelore.game-resource',
- title: '游戏资源生成',
- stableVersion: '1.0.0',
- betaVersion: null,
- };
- render();
-
- expect(screen.getByText('随 MakeLore 提供')).toBeVisible();
- expect(screen.queryByText('尚未下载到设备')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: '下载游戏资源生成' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: '更新游戏资源生成' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: '删除设备上的游戏资源生成' })).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: '移除游戏资源生成' })).toBeVisible();
- expect(screen.getByRole('link', { name: '启用到项目' })).toBeVisible();
- });
-
- it('updates an installed Beta package only through the explicit Beta action', () => {
- const onUpdate = vi.fn();
- const onInstallBeta = vi.fn();
- render();
-
- expect(screen.queryByRole('button', { name: '更新灵感笔记' })).not.toBeInTheDocument();
- const updateBeta = screen.getByRole('button', { name: '更新 Beta灵感笔记' });
- fireEvent.click(updateBeta);
- expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes');
- expect(onUpdate).not.toHaveBeenCalled();
- expect(screen.getByText('当前频道:Beta')).toBeVisible();
- });
-
- it('keeps a failed Beta update on the explicit Beta route', () => {
- const onUpdate = vi.fn();
- const onInstallBeta = vi.fn();
- render();
-
- fireEvent.click(screen.getByRole('button', { name: '更新 Beta灵感笔记' }));
- expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes');
- expect(onUpdate).not.toHaveBeenCalled();
- expect(screen.getByText('当前频道:Beta')).toBeVisible();
- });
-
- it('does not silently fall back to stable when the installed Beta channel has no release', () => {
- render();
- expect(screen.getByText('当前 Beta 频道暂无可用版本;不会静默切回稳定版。')).toBeVisible();
- expect(screen.queryByRole('button', { name: '更新灵感笔记' })).not.toBeInTheDocument();
- });
-
- it('names signature, yanked/not-ready, and incompatible failures without hiding the old install', () => {
- render();
- expect(screen.getByText(/签名校验失败/)).toBeVisible();
- expect(screen.getByText(/此版本已撤回/)).toBeVisible();
- expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible();
- expect(screen.getByText('设备版本 1.5.0')).toBeVisible();
- });
-
- it('does not present a no-version unavailable projection as an installed device package', () => {
- render();
-
- expect(screen.getByText('尚未下载到设备')).toBeVisible();
- expect(screen.getByRole('button', { name: '下载灵感笔记' })).toBeVisible();
- expect(screen.queryByRole('button', { name: '删除设备上的灵感笔记' })).not.toBeInTheDocument();
- expect(screen.queryByText('设备版本已是最新')).not.toBeInTheDocument();
- expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible();
- });
-
- it('honestly groups stable Main failure codes without claiming hidden authority', () => {
- const { rerender } = render();
- expect(screen.getByRole('alert')).toHaveTextContent('签名或包校验失败');
- rerender();
- expect(screen.getByRole('alert')).toHaveTextContent('版本尚未就绪;不会替换此前可用版本');
- });
-});
diff --git a/tests/unit/plugin-navigation.test.tsx b/tests/unit/plugin-navigation.test.tsx
new file mode 100644
index 0000000..1b43c14
--- /dev/null
+++ b/tests/unit/plugin-navigation.test.tsx
@@ -0,0 +1,43 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
+import { describe, expect, it } from 'vitest';
+import { LegacyPluginRedirect } from '@/pages/Plugins/legacy-plugin-redirect';
+import { getGuardedAiModuleForPath, isProgrammingProviderRoute } from '@/lib/ai-modules';
+
+function LocationProbe() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ return (
+
+
+
+
+ );
+}
+
+describe('plugin navigation cutover', () => {
+ it.each([
+ ['/plugin-marketplace', '/plugins?scope=all&source=official'],
+ ['/my-plugins', '/plugins?scope=all&state=mine'],
+ ['/project-plugins', '/plugins?scope=project'],
+ ] as const)('replace-redirects %s to %s', async (legacy, canonical) => {
+ render(
+
+
+ } />
+ } />
+ previous} />
+
+ ,
+ );
+
+ expect(await screen.findByTestId('location')).toHaveTextContent(canonical);
+ fireEvent.click(screen.getByRole('button', { name: '返回' }));
+ expect(await screen.findByTestId('previous')).toBeVisible();
+ });
+
+ it('classifies the canonical page as Code without making it provider-gated', () => {
+ expect(getGuardedAiModuleForPath('/plugins')).toBe('programming');
+ expect(isProgrammingProviderRoute('/plugins')).toBe(false);
+ });
+});
diff --git a/tests/unit/plugin-workspace-controller.test.ts b/tests/unit/plugin-workspace-controller.test.ts
new file mode 100644
index 0000000..b13899b
--- /dev/null
+++ b/tests/unit/plugin-workspace-controller.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ dispatchPluginWorkspaceCommand,
+ settlePluginWorkspaceLoads,
+ type PluginWorkspaceDispatchDependencies,
+} from '@/pages/Plugins/plugin-workspace-controller';
+import type { PluginWorkspaceCommand } from '@/pages/Plugins/plugin-workspace-model';
+
+function dependencies() {
+ return {
+ marketplace: {
+ acquire: vi.fn().mockResolvedValue(undefined),
+ remove: vi.fn().mockResolvedValue(undefined),
+ install: vi.fn().mockResolvedValue(undefined),
+ installBeta: vi.fn().mockResolvedValue(undefined),
+ update: vi.fn().mockResolvedValue(undefined),
+ uninstall: vi.fn().mockResolvedValue(undefined),
+ },
+ device: {
+ setEnabled: vi.fn().mockResolvedValue(undefined),
+ uninstall: vi.fn().mockResolvedValue(undefined),
+ },
+ project: {
+ setEnabled: vi.fn().mockResolvedValue(undefined),
+ },
+ navigate: vi.fn(),
+ openSettings: vi.fn(),
+ } satisfies PluginWorkspaceDispatchDependencies;
+}
+
+describe('plugin workspace controller', () => {
+ it('dispatches every command to exactly one existing authority action', async () => {
+ const deps = dependencies();
+ const commands = [
+ { kind: 'acquire', pluginId: 'a' },
+ { kind: 'reacquire', pluginId: 'b' },
+ { kind: 'remove_from_library', pluginId: 'c' },
+ { kind: 'install_stable', pluginId: 'd' },
+ { kind: 'install_beta', pluginId: 'e' },
+ { kind: 'update_official', pluginId: 'f' },
+ { kind: 'remove_official_device_package', pluginId: 'g' },
+ { kind: 'enable_project', projectId: 'project-a', pluginId: 'h' },
+ { kind: 'disable_project', projectId: 'project-a', pluginId: 'i' },
+ { kind: 'enable_local', packageId: 'j' },
+ { kind: 'disable_local', packageId: 'k' },
+ { kind: 'remove_local', packageId: 'l' },
+ { kind: 'open_agent_assignment', projectId: 'project-a', pluginId: 'm' },
+ { kind: 'open_settings', projectId: 'project-a', pluginId: 'n' },
+ ] satisfies readonly PluginWorkspaceCommand[];
+
+ for (const command of commands) await dispatchPluginWorkspaceCommand(command, deps);
+
+ expect(deps.marketplace.acquire.mock.calls).toEqual([['a'], ['b']]);
+ expect(deps.marketplace.remove).toHaveBeenCalledExactlyOnceWith('c');
+ expect(deps.marketplace.install).toHaveBeenCalledExactlyOnceWith('d');
+ expect(deps.marketplace.installBeta).toHaveBeenCalledExactlyOnceWith('e');
+ expect(deps.marketplace.update).toHaveBeenCalledExactlyOnceWith('f');
+ expect(deps.marketplace.uninstall).toHaveBeenCalledExactlyOnceWith('g');
+ expect(deps.project.setEnabled.mock.calls).toEqual([
+ ['project-a', 'h', true],
+ ['project-a', 'i', false],
+ ]);
+ expect(deps.device.setEnabled.mock.calls).toEqual([['j', true], ['k', false]]);
+ expect(deps.device.uninstall).toHaveBeenCalledExactlyOnceWith('l');
+ expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/project-config');
+ expect(deps.openSettings).toHaveBeenCalledExactlyOnceWith();
+ });
+
+ it('starts every available source load even when one source rejects', async () => {
+ const catalog = vi.fn().mockRejectedValue(new Error('catalog offline'));
+ const library = vi.fn().mockResolvedValue(undefined);
+ const device = vi.fn().mockResolvedValue(undefined);
+ const project = vi.fn().mockResolvedValue(undefined);
+
+ const results = await settlePluginWorkspaceLoads({ catalog, library, device, project });
+
+ expect(catalog).toHaveBeenCalledOnce();
+ expect(library).toHaveBeenCalledOnce();
+ expect(device).toHaveBeenCalledOnce();
+ expect(project).toHaveBeenCalledOnce();
+ expect(results.map(({ status }) => status)).toEqual(['rejected', 'fulfilled', 'fulfilled', 'fulfilled']);
+ });
+});
diff --git a/tests/unit/plugin-workspace-model.test.ts b/tests/unit/plugin-workspace-model.test.ts
new file mode 100644
index 0000000..b7b12ae
--- /dev/null
+++ b/tests/unit/plugin-workspace-model.test.ts
@@ -0,0 +1,602 @@
+import { describe, expect, it } from 'vitest';
+import type { CodingPluginProject } from '@/lib/coding-plugins';
+import type {
+ MarketplaceCatalogPage,
+ MarketplaceLibrarySnapshot,
+} from '@/lib/plugin-marketplace';
+import type { DevicePackageIndexV1, DevicePackageRecordV1 } from '../../shared/device-packages';
+import {
+ buildPluginWorkspaceProjection,
+ type PluginWorkspaceInputs,
+} from '@/pages/Plugins/plugin-workspace-model';
+
+const catalog: MarketplaceCatalogPage = {
+ items: [{
+ pluginId: 'makelore.notes',
+ title: 'Notes',
+ summary: 'Write project notes',
+ category: 'productivity',
+ tags: ['notes'],
+ providerDisplayName: 'MakeLore',
+ runtimeKind: 'skill_only',
+ runtimeStatus: 'enabled',
+ acquisition: 'free',
+ usageBilling: 'token_point',
+ includedOperationCount: 0,
+ meteredOperationCount: 1,
+ stableVersion: '2.0.0',
+ betaVersion: '3.0.0-beta.1',
+ }],
+ nextCursor: null,
+ total: 1,
+ catalogGeneration: 4,
+ etag: 'catalog-4',
+ pricingVersionId: 'pricing-1',
+ stale: false,
+ fetchedAt: 4,
+};
+
+const library: MarketplaceLibrarySnapshot = {
+ items: [{
+ pluginId: 'makelore.notes',
+ title: 'Notes',
+ summary: 'Write project notes',
+ category: 'productivity',
+ acquisition: 'free',
+ acquisitionMode: 'user_acquired',
+ catalogStatus: 'active',
+ runtimeStatus: 'enabled',
+ acquiredAt: '2026-09-03T00:00:00Z',
+ removedAt: null,
+ stableVersion: '2.0.0',
+ betaVersion: '3.0.0-beta.1',
+ }],
+ total: 1,
+ stale: false,
+ fetchedAt: 4,
+};
+
+const project: CodingPluginProject = {
+ schemaVersion: 1,
+ project: { localProjectId: 'project-a', durableProjectId: 'durable-a' },
+ policyStatus: 'current',
+ unknownPluginIds: [],
+ items: [{
+ id: 'makelore.notes',
+ version: '1.0.0',
+ contractVersion: 1,
+ requiresBackend: false,
+ displayName: 'Notes',
+ description: 'Write project notes',
+ enabled: true,
+ state: 'ready',
+ backend: { status: 'not_required' },
+ skills: [{ id: 'notes', assignedAgentIds: ['agent-a'] }],
+ capabilities: [{
+ id: 'notes',
+ operations: [{
+ id: 'write',
+ billing: {
+ mode: 'platform_metered',
+ availability: 'available',
+ notice: 'Metered',
+ unitName: 'request',
+ },
+ tool: null,
+ }],
+ }],
+ settingsSurface: null,
+ }],
+};
+
+function input(overrides: Partial = {}): PluginWorkspaceInputs {
+ return {
+ catalog,
+ details: {},
+ library,
+ marketplaceInstallations: {
+ 'makelore.notes': {
+ status: 'installed',
+ pluginId: 'makelore.notes',
+ releaseId: 'release-1',
+ version: '1.0.0',
+ channel: 'stable',
+ },
+ },
+ devicePackages: {
+ schemaVersion: 1,
+ generation: 1,
+ packages: [],
+ } satisfies DevicePackageIndexV1,
+ project,
+ activeProject: { id: 'project-a', name: 'Project A' },
+ agentNames: { 'agent-a': 'Alice' },
+ filters: { scope: 'all', source: 'all', state: 'all', search: '' },
+ ...overrides,
+ };
+}
+
+function commandKinds(overrides: Partial): string[] {
+ return buildPluginWorkspaceProjection(input(overrides)).items[0]?.commands.map(({ kind }) => kind) ?? [];
+}
+
+function devicePackage(packageId: string, displayName: string, enabled: boolean): DevicePackageRecordV1 {
+ return {
+ schemaVersion: 1,
+ packageId,
+ displayName,
+ resolvedVersion: '1.0.0',
+ source: { kind: 'npm', requested: packageId, resolved: `${packageId}@1.0.0` },
+ kind: 'skill-only',
+ skillEntries: [],
+ extensionEntries: [],
+ enabled,
+ confirmedExecutableCode: false,
+ installedAt: '2026-09-03T00:00:00Z',
+ };
+}
+
+describe('buildPluginWorkspaceProjection', () => {
+ it('merges every official source into one item with only its legal commands', () => {
+ const result = buildPluginWorkspaceProjection(input());
+
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0]).toMatchObject({
+ key: 'official:makelore.notes',
+ source: 'official',
+ pluginId: 'makelore.notes',
+ delivery: 'account_acquired',
+ projectState: 'enabled',
+ assignedAgentNames: ['Alice'],
+ updateAvailable: true,
+ });
+ expect(result.items[0]?.commands.map(({ kind }) => kind)).toEqual([
+ 'remove_from_library',
+ 'install_beta',
+ 'update_official',
+ 'remove_official_device_package',
+ 'disable_project',
+ 'open_agent_assignment',
+ ]);
+ });
+
+ it('keeps official and local identities separate while excluding only the native web-search tool', () => {
+ const nativeCatalogItem = {
+ ...catalog.items[0],
+ pluginId: 'makelore_web_search',
+ title: 'Native Web Search',
+ };
+ const result = buildPluginWorkspaceProjection(input({
+ catalog: { ...catalog, items: [...catalog.items, nativeCatalogItem], total: 2 },
+ devicePackages: {
+ schemaVersion: 1,
+ generation: 2,
+ packages: [
+ {
+ schemaVersion: 1,
+ packageId: 'makelore.notes',
+ displayName: 'Local Notes',
+ resolvedVersion: '4.0.0',
+ source: { kind: 'npm', requested: 'makelore.notes', resolved: 'makelore.notes@4.0.0' },
+ kind: 'skill-only',
+ skillEntries: [{ id: 'notes', entryPath: 'skills/notes/SKILL.md' }],
+ extensionEntries: [],
+ enabled: true,
+ confirmedExecutableCode: false,
+ installedAt: '2026-09-03T00:00:00Z',
+ },
+ {
+ schemaVersion: 1,
+ packageId: 'makelore_web_search',
+ displayName: 'Third-party Web Search',
+ resolvedVersion: '1.0.0',
+ source: { kind: 'git', requested: 'example/web-search', resolved: 'https://example.test/web-search.git' },
+ kind: 'pi-extension',
+ skillEntries: [],
+ extensionEntries: ['index.js'],
+ enabled: false,
+ confirmedExecutableCode: true,
+ installedAt: '2026-09-03T00:00:00Z',
+ },
+ ],
+ },
+ }));
+
+ expect(result.items.map(({ key }) => key)).toEqual([
+ 'official:makelore.notes',
+ 'local:makelore.notes',
+ 'local:makelore_web_search',
+ ]);
+ expect(result.items[1]).toMatchObject({
+ source: 'local',
+ delivery: 'local_installed',
+ projectState: 'not_applicable',
+ localEnabled: true,
+ billing: 'none',
+ });
+ expect(result.items[1]?.commands.map(({ kind }) => kind)).toEqual(['disable_local', 'remove_local']);
+ expect(result.items[2]?.commands.map(({ kind }) => kind)).toEqual(['enable_local', 'remove_local']);
+ });
+
+ it('never offers account or device commands for a system-included plugin', () => {
+ const systemCatalog = {
+ ...catalog.items[0]!,
+ pluginId: 'makelore.data-service',
+ title: 'Data Service',
+ acquisition: 'system_included' as const,
+ runtimeKind: 'bundled_typed' as const,
+ };
+ const systemLibrary = {
+ ...library.items[0]!,
+ pluginId: systemCatalog.pluginId,
+ title: systemCatalog.title,
+ acquisition: 'system_included' as const,
+ acquisitionMode: 'system_included' as const,
+ };
+ const systemProject = {
+ ...project,
+ items: [{
+ ...project.items[0]!,
+ id: systemCatalog.pluginId,
+ displayName: systemCatalog.title,
+ settingsSurface: 'data-service',
+ }],
+ };
+
+ expect(commandKinds({
+ catalog: { ...catalog, items: [systemCatalog] },
+ library: { ...library, items: [systemLibrary] },
+ project: systemProject,
+ marketplaceInstallations: {
+ [systemCatalog.pluginId]: {
+ status: 'installed',
+ pluginId: systemCatalog.pluginId,
+ releaseId: 'should-be-ignored',
+ version: '0.1.0',
+ channel: 'stable',
+ },
+ },
+ })).toEqual(['disable_project', 'open_agent_assignment', 'open_settings']);
+ });
+
+ it('keeps bundled and downloadable official command sets inside their delivery boundaries', () => {
+ const bundledCatalog = {
+ ...catalog.items[0]!,
+ pluginId: 'makelore.game-resource',
+ title: 'Game Resource',
+ runtimeKind: 'bundled_typed' as const,
+ };
+ const bundledLibrary = {
+ ...library.items[0]!,
+ pluginId: bundledCatalog.pluginId,
+ title: bundledCatalog.title,
+ };
+ const bundledProject = {
+ ...project,
+ items: [{
+ ...project.items[0]!,
+ id: bundledCatalog.pluginId,
+ displayName: bundledCatalog.title,
+ enabled: false,
+ state: 'disabled' as const,
+ }],
+ };
+
+ expect(commandKinds({
+ catalog: { ...catalog, items: [bundledCatalog] },
+ library: { ...library, items: [] },
+ marketplaceInstallations: {},
+ project: null,
+ })).toEqual(['acquire']);
+ expect(commandKinds({
+ catalog: { ...catalog, items: [bundledCatalog] },
+ library: { ...library, items: [bundledLibrary] },
+ marketplaceInstallations: {},
+ project: bundledProject,
+ })).toEqual(['remove_from_library', 'enable_project', 'open_agent_assignment']);
+
+ expect(commandKinds({ marketplaceInstallations: {} })).toEqual([
+ 'remove_from_library',
+ 'install_stable',
+ 'install_beta',
+ 'disable_project',
+ ]);
+ });
+
+ it('allows recovery and cleanup, but no new delivery, for removed or unavailable official items', () => {
+ const removed = { ...library.items[0]!, removedAt: '2026-09-03T01:00:00Z' };
+ expect(commandKinds({ library: { ...library, items: [removed] } })).toEqual([
+ 'reacquire',
+ 'remove_official_device_package',
+ 'disable_project',
+ ]);
+
+ expect(commandKinds({
+ library: { ...library, items: [{ ...removed, catalogStatus: 'retired' }] },
+ })).toEqual(['remove_official_device_package', 'disable_project']);
+
+ expect(commandKinds({
+ catalog: {
+ ...catalog,
+ items: [{ ...catalog.items[0]!, runtimeStatus: 'suspended' }],
+ },
+ library: {
+ ...library,
+ items: [{ ...library.items[0]!, runtimeStatus: 'suspended' }],
+ },
+ })).toEqual([
+ 'remove_from_library',
+ 'remove_official_device_package',
+ 'disable_project',
+ ]);
+ });
+
+ it('keeps an installed Beta update on the explicit Beta command', () => {
+ expect(commandKinds({
+ marketplaceInstallations: {
+ 'makelore.notes': {
+ status: 'installed',
+ pluginId: 'makelore.notes',
+ releaseId: 'beta-old',
+ version: '2.0.0-beta.1',
+ channel: 'beta',
+ },
+ },
+ })).toEqual([
+ 'remove_from_library',
+ 'install_beta',
+ 'remove_official_device_package',
+ 'disable_project',
+ 'open_agent_assignment',
+ ]);
+ });
+
+ it('orders source groups deterministically and creates retained items only for unexplained project ids', () => {
+ const libraryOnly = {
+ ...library.items[0]!,
+ pluginId: 'makelore.library-only',
+ title: 'Library Only',
+ };
+ const projectOnly = {
+ ...project.items[0]!,
+ id: 'makelore.project-only',
+ displayName: 'Project Only',
+ enabled: false,
+ state: 'disabled' as const,
+ };
+ const localPackage = (packageId: string, displayName: string) => ({
+ schemaVersion: 1 as const,
+ packageId,
+ displayName,
+ resolvedVersion: '1.0.0',
+ source: { kind: 'npm' as const, requested: packageId, resolved: `${packageId}@1.0.0` },
+ kind: 'skill-only' as const,
+ skillEntries: [],
+ extensionEntries: [],
+ enabled: true,
+ confirmedExecutableCode: false,
+ installedAt: '2026-09-03T00:00:00Z',
+ });
+ const overrides: Partial = {
+ library: { ...library, items: [library.items[0]!, libraryOnly], total: 2 },
+ marketplaceInstallations: {},
+ project: {
+ ...project,
+ items: [project.items[0]!, projectOnly],
+ unknownPluginIds: ['makelore.notes', 'makelore.ghost', 'makelore_web_search'],
+ },
+ devicePackages: {
+ schemaVersion: 1,
+ generation: 3,
+ packages: [localPackage('zeta.package', 'Zeta'), localPackage('alpha.package', 'Alpha')],
+ },
+ };
+
+ expect(buildPluginWorkspaceProjection(input(overrides)).items.map(({ key }) => key)).toEqual([
+ 'official:makelore.notes',
+ 'official:makelore.library-only',
+ 'official:makelore.project-only',
+ 'local:alpha.package',
+ 'local:zeta.package',
+ 'retained:makelore.ghost',
+ ]);
+
+ const projectScope = buildPluginWorkspaceProjection(input({
+ ...overrides,
+ filters: { scope: 'project', source: 'all', state: 'all', search: '' },
+ }));
+ expect(projectScope.items.map(({ key }) => key)).toEqual([
+ 'official:makelore.notes',
+ 'official:makelore.project-only',
+ 'local:alpha.package',
+ 'local:zeta.package',
+ 'retained:makelore.ghost',
+ ]);
+ expect(projectScope.items.at(-1)?.commands).toEqual([{
+ kind: 'disable_project',
+ projectId: 'project-a',
+ pluginId: 'makelore.ghost',
+ }]);
+ });
+
+ it('applies scope, source, state, and search filters while keeping state counts stable', () => {
+ const available = {
+ ...catalog.items[0]!,
+ pluginId: 'makelore.available',
+ title: 'Available Writer',
+ summary: 'A fresh plugin',
+ betaVersion: null,
+ };
+ const suspended = {
+ ...catalog.items[0]!,
+ pluginId: 'makelore.suspended',
+ title: 'Suspended Tool',
+ runtimeStatus: 'suspended' as const,
+ betaVersion: null,
+ };
+ const suspendedProject = {
+ ...project.items[0]!,
+ id: suspended.pluginId,
+ displayName: suspended.title,
+ enabled: false,
+ state: 'unavailable' as const,
+ };
+ const sources: Partial = {
+ catalog: { ...catalog, items: [catalog.items[0]!, available, suspended], total: 3 },
+ project: {
+ ...project,
+ items: [project.items[0]!, suspendedProject],
+ unknownPluginIds: ['makelore.ghost'],
+ },
+ devicePackages: {
+ schemaVersion: 1,
+ generation: 4,
+ packages: [
+ devicePackage('alpha.package', 'Alpha Local', true),
+ devicePackage('zeta.package', 'Zeta Local', false),
+ ],
+ },
+ };
+ const projectAll = (filters: PluginWorkspaceInputs['filters']) => buildPluginWorkspaceProjection(input({
+ ...sources,
+ filters,
+ }));
+
+ const all = projectAll({ scope: 'all', source: 'all', state: 'all', search: '' });
+ expect(all.counts).toEqual({ all: 6, available: 1, mine: 3, enabled: 2, update: 1, unavailable: 2 });
+
+ const expectedByState = {
+ available: ['official:makelore.available'],
+ mine: ['official:makelore.notes', 'local:alpha.package', 'local:zeta.package'],
+ enabled: ['official:makelore.notes', 'local:alpha.package'],
+ update: ['official:makelore.notes'],
+ unavailable: ['official:makelore.suspended', 'retained:makelore.ghost'],
+ } as const;
+ for (const [state, expected] of Object.entries(expectedByState)) {
+ const result = projectAll({
+ scope: 'all',
+ source: 'all',
+ state: state as keyof typeof expectedByState,
+ search: '',
+ });
+ expect(result.items.map(({ key }) => key), state).toEqual(expected);
+ expect(result.counts, state).toEqual(all.counts);
+ }
+
+ expect(projectAll({
+ scope: 'all', source: 'official', state: 'all', search: 'fresh',
+ }).items.map(({ key }) => key)).toEqual(['official:makelore.available']);
+ expect(projectAll({
+ scope: 'all', source: 'local', state: 'all', search: 'alpha.package',
+ }).items.map(({ key }) => key)).toEqual(['local:alpha.package']);
+ expect(projectAll({
+ scope: 'project', source: 'all', state: 'all', search: '',
+ }).items.map(({ key }) => key)).toEqual([
+ 'official:makelore.notes',
+ 'official:makelore.suspended',
+ 'local:alpha.package',
+ 'local:zeta.package',
+ 'retained:makelore.ghost',
+ ]);
+ });
+
+ it('falls back from project scope without an active project and closes invalid selections', () => {
+ const fallback = buildPluginWorkspaceProjection(input({
+ activeProject: null,
+ project: null,
+ filters: {
+ scope: 'project',
+ source: 'all',
+ state: 'all',
+ search: '',
+ selectedKey: 'official:makelore.notes',
+ },
+ }));
+ expect(fallback.items.map(({ key }) => key)).toEqual(['official:makelore.notes']);
+ expect(fallback.selected?.key).toBe('official:makelore.notes');
+ expect(fallback.notices).toContainEqual(expect.objectContaining({ kind: 'scope_fallback' }));
+
+ const hiddenSelection = buildPluginWorkspaceProjection(input({
+ filters: {
+ scope: 'all',
+ source: 'local',
+ state: 'all',
+ search: '',
+ selectedKey: 'official:makelore.notes',
+ },
+ }));
+ expect(hiddenSelection.selected).toBeNull();
+ });
+
+ it('never lets a late detail for A replace the selected B item', () => {
+ const lateNotesDetail = {
+ ...catalog.items[0],
+ descriptionMarkdown: 'Late detail for Notes',
+ permissions: [],
+ operations: [],
+ stableRelease: { releaseId: 'notes-2', version: '2.0.0' },
+ betaRelease: null,
+ etag: 'notes-detail',
+ pricingVersionId: null,
+ stale: false,
+ fetchedAt: 5,
+ };
+ const tasks = {
+ ...catalog.items[0],
+ pluginId: 'makelore.tasks',
+ title: 'Tasks',
+ summary: 'Track tasks',
+ };
+ const result = buildPluginWorkspaceProjection(input({
+ catalog: { ...catalog, items: [catalog.items[0], tasks], total: 2 },
+ details: { 'makelore.notes': lateNotesDetail },
+ library: null,
+ marketplaceInstallations: {},
+ project: null,
+ activeProject: null,
+ filters: {
+ scope: 'all', source: 'all', state: 'all', search: '', selectedKey: 'official:makelore.tasks',
+ },
+ }));
+
+ expect(result.selected).toMatchObject({ key: 'official:makelore.tasks', title: 'Tasks' });
+ expect(result.selected?.official?.detail).toBeNull();
+ });
+
+ it('keeps independent sources visible and does not infer acquisition when Library is unavailable', () => {
+ const local = devicePackage('local.notes', 'Local Notes', true);
+ const unknownLibrary = buildPluginWorkspaceProjection(input({
+ library: null,
+ marketplaceInstallations: {},
+ project: null,
+ devicePackages: { schemaVersion: 1, generation: 5, packages: [local] },
+ }));
+ expect(unknownLibrary.items.map(({ key }) => key)).toEqual([
+ 'official:makelore.notes',
+ 'local:local.notes',
+ ]);
+ expect(unknownLibrary.items[0]).toMatchObject({
+ delivery: 'account_unknown',
+ commands: [],
+ });
+
+ const unavailableCatalog = buildPluginWorkspaceProjection(input({
+ catalog: null,
+ project: null,
+ devicePackages: { schemaVersion: 1, generation: 5, packages: [local] },
+ }));
+ expect(unavailableCatalog.items.map(({ key }) => key)).toEqual([
+ 'official:makelore.notes',
+ 'local:local.notes',
+ ]);
+
+ const unavailableDevice = buildPluginWorkspaceProjection(input({ devicePackages: null }));
+ expect(unavailableDevice.items.map(({ key }) => key)).toEqual(['official:makelore.notes']);
+
+ const unavailableProject = buildPluginWorkspaceProjection(input({ project: null }));
+ expect(unavailableProject.items[0]).toMatchObject({
+ key: 'official:makelore.notes',
+ projectState: 'unknown',
+ });
+ });
+});
diff --git a/tests/unit/plugin-workspace-query.test.ts b/tests/unit/plugin-workspace-query.test.ts
new file mode 100644
index 0000000..668415a
--- /dev/null
+++ b/tests/unit/plugin-workspace-query.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import {
+ parsePluginWorkspaceSearch,
+ serializePluginWorkspaceSearch,
+} from '@/pages/Plugins/plugin-workspace-query';
+
+describe('plugin workspace URL state', () => {
+ it('uses the hydrated project presence for a bare route default', () => {
+ expect(parsePluginWorkspaceSearch(new URLSearchParams(), true)).toEqual({
+ scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
+ });
+ expect(parsePluginWorkspaceSearch(new URLSearchParams(), false)).toEqual({
+ scope: 'all', source: 'all', state: 'all', search: '', selectedKey: null,
+ });
+ });
+
+ it('normalizes unknown values and a project scope without a project', () => {
+ const params = new URLSearchParams('scope=unknown&source=remote&state=broken&q=%20notes%20&plugin=notes');
+ expect(parsePluginWorkspaceSearch(params, true)).toEqual({
+ scope: 'project', source: 'all', state: 'all', search: 'notes', selectedKey: null,
+ });
+ expect(parsePluginWorkspaceSearch(new URLSearchParams('scope=project'), false).scope).toBe('all');
+ });
+
+ it('round-trips canonical filters and source-qualified selection', () => {
+ const serialized = serializePluginWorkspaceSearch({
+ scope: 'all',
+ source: 'local',
+ state: 'enabled',
+ search: ' web search ',
+ selectedKey: 'local:makelore_web_search',
+ });
+ expect(serialized.toString()).toBe(
+ 'scope=all&source=local&state=enabled&q=web+search&plugin=local%3Amakelore_web_search',
+ );
+ expect(parsePluginWorkspaceSearch(serialized, true)).toEqual({
+ scope: 'all',
+ source: 'local',
+ state: 'enabled',
+ search: 'web search',
+ selectedKey: 'local:makelore_web_search',
+ });
+ });
+});
diff --git a/tests/unit/plugins-page.test.tsx b/tests/unit/plugins-page.test.tsx
new file mode 100644
index 0000000..e191480
--- /dev/null
+++ b/tests/unit/plugins-page.test.tsx
@@ -0,0 +1,287 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { useState } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+import { PluginsView } from '@/pages/Plugins';
+import type {
+ PluginWorkspaceFilters,
+ PluginWorkspaceItem,
+ PluginWorkspaceProjection,
+} from '@/pages/Plugins/plugin-workspace-model';
+
+const filters: PluginWorkspaceFilters = {
+ scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
+};
+
+const projectPlugin = {
+ id: 'makelore.data-service',
+ version: '1.0.0',
+ contractVersion: 1,
+ requiresBackend: true,
+ displayName: '开发数据服务',
+ description: '为当前项目提供 JSON 数据。',
+ enabled: true,
+ state: 'configuration_required' as const,
+ backend: { status: 'unconfigured' as const },
+ skills: [{ id: 'data-service', assignedAgentIds: ['agent-a'] }],
+ capabilities: [{
+ id: 'data-service.documents',
+ operations: [{
+ id: 'put_document',
+ billing: { mode: 'included' as const, availability: 'available' as const, notice: 'Fixed quotas apply' },
+ tool: {
+ name: 'data_service_put_document',
+ label: '写入文档',
+ description: '写入项目数据',
+ mutation: 'write' as const,
+ permissions: ['project.data.write'],
+ },
+ }],
+ }],
+ settingsSurface: 'data-service',
+};
+
+const officialItem: PluginWorkspaceItem = {
+ key: 'official:makelore.data-service',
+ source: 'official',
+ pluginId: 'makelore.data-service',
+ packageId: null,
+ title: '开发数据服务',
+ summary: '为当前项目提供 JSON 数据。',
+ category: '系统',
+ tags: ['data'],
+ publisher: 'MakeLore',
+ version: '1.0.0',
+ delivery: 'system_included',
+ projectState: 'enabled',
+ localEnabled: null,
+ assignedAgentIds: ['agent-a'],
+ assignedAgentNames: ['小明'],
+ billing: 'included',
+ updateAvailable: false,
+ unavailable: false,
+ stale: false,
+ commands: [
+ { kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.data-service' },
+ { kind: 'open_agent_assignment', projectId: 'project-a', pluginId: 'makelore.data-service' },
+ { kind: 'open_settings', projectId: 'project-a', pluginId: 'makelore.data-service' },
+ ],
+ official: {
+ catalog: null,
+ library: null,
+ installation: null,
+ project: projectPlugin,
+ detail: {
+ pluginId: 'makelore.data-service',
+ title: '开发数据服务',
+ summary: '为当前项目提供 JSON 数据。',
+ category: '系统',
+ tags: ['data'],
+ providerDisplayName: 'MakeLore',
+ runtimeKind: 'bundled_typed',
+ runtimeStatus: 'enabled',
+ acquisition: 'system_included',
+ usageBilling: 'included',
+ includedOperationCount: 1,
+ meteredOperationCount: 0,
+ descriptionMarkdown: '详细的数据服务介绍。',
+ permissions: ['读取项目名称'],
+ operations: [],
+ stableVersion: '1.0.0',
+ betaVersion: null,
+ stableRelease: { releaseId: 'data-1', version: '1.0.0' },
+ betaRelease: null,
+ etag: 'data-1',
+ pricingVersionId: null,
+ stale: false,
+ fetchedAt: 1,
+ },
+ },
+ local: null,
+};
+
+const localItem: PluginWorkspaceItem = {
+ key: 'local:pi-web-search',
+ source: 'local',
+ pluginId: 'pi-web-search',
+ packageId: 'pi-web-search',
+ title: 'Pi Web Search',
+ summary: 'npm:pi-web-search',
+ category: null,
+ tags: ['search'],
+ publisher: null,
+ version: '1.2.3',
+ delivery: 'local_installed',
+ projectState: 'not_applicable',
+ localEnabled: true,
+ assignedAgentIds: [],
+ assignedAgentNames: [],
+ billing: 'none',
+ updateAvailable: false,
+ unavailable: false,
+ stale: false,
+ commands: [
+ { kind: 'disable_local', packageId: 'pi-web-search' },
+ { kind: 'remove_local', packageId: 'pi-web-search' },
+ ],
+ official: null,
+ local: {
+ schemaVersion: 1,
+ packageId: 'pi-web-search',
+ displayName: 'Pi Web Search',
+ resolvedVersion: '1.2.3',
+ source: { kind: 'npm', requested: 'npm:pi-web-search', resolved: 'npm:pi-web-search@1.2.3' },
+ kind: 'mixed',
+ skillEntries: [{ id: 'search', entryPath: 'skills/search/SKILL.md' }],
+ extensionEntries: ['extensions/search.ts'],
+ enabled: true,
+ confirmedExecutableCode: true,
+ installedAt: '2026-09-03T00:00:00Z',
+ },
+};
+
+function projection(selected: PluginWorkspaceItem | null = null): PluginWorkspaceProjection {
+ return {
+ items: [officialItem, localItem],
+ selected,
+ counts: { all: 2, available: 0, mine: 2, enabled: 2, update: 0, unavailable: 0 },
+ notices: [],
+ };
+}
+
+function props(selected: PluginWorkspaceItem | null = null) {
+ return {
+ projection: projection(selected),
+ filters: { ...filters, selectedKey: selected?.key ?? null },
+ activeProjectId: 'project-a',
+ activeProjectName: 'Project A',
+ sourceErrors: [],
+ sourceLoading: [],
+ dataService: null,
+ dataServicePending: {},
+ onFiltersChange: vi.fn(),
+ onRefresh: vi.fn(),
+ onSelect: vi.fn(),
+ onCommand: vi.fn(),
+ isCommandPending: vi.fn().mockReturnValue(false),
+ onConfigureDataService: vi.fn(),
+ onResetDataService: vi.fn(),
+ onRemoveDataServiceCollection: vi.fn(),
+ onRemoveDataServiceProject: vi.fn(),
+ };
+}
+
+describe('PluginsView', () => {
+ it('renders the unified workspace and exposes URL-backed filter changes', () => {
+ const view = props();
+ view.sourceErrors = [
+ { source: 'catalog', message: 'catalog offline' },
+ { source: 'project', message: 'project unavailable' },
+ ];
+ render();
+
+ expect(screen.getByRole('heading', { name: '插件' })).toBeVisible();
+ expect(screen.getByText(/MakeLore 运营发布官方插件/)).toBeVisible();
+ expect(screen.getByText(/获取、项目启用与伙伴分配彼此独立/)).toBeVisible();
+ expect(screen.getByText('开发数据服务')).toBeVisible();
+ expect(screen.getByText('Pi Web Search')).toBeVisible();
+ expect(screen.getByRole('heading', { name: '本机全局生效' })).toBeVisible();
+ expect(screen.getByText('本机全局已启用')).toBeVisible();
+ expect(screen.getByText('随 MakeLore 提供')).toBeVisible();
+ expect(screen.getByText('已分配 1 位伙伴')).toBeVisible();
+ expect(screen.getAllByRole('alert')).toHaveLength(2);
+ expect(screen.queryByRole('textbox', { name: /包|路径|source/i })).not.toBeInTheDocument();
+ expect(screen.getByText(/通过对话安装/)).toBeVisible();
+
+ fireEvent.change(screen.getByLabelText('范围'), { target: { value: 'all' } });
+ expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ scope: 'all' }));
+ fireEvent.change(screen.getByLabelText('来源'), { target: { value: 'local' } });
+ expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ source: 'local' }));
+ fireEvent.change(screen.getByLabelText('状态'), { target: { value: 'enabled' } });
+ expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ state: 'enabled' }));
+ fireEvent.change(screen.getByRole('searchbox', { name: '搜索插件' }), { target: { value: 'web' } });
+ fireEvent.submit(screen.getByRole('search'));
+ expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ search: 'web' }));
+ });
+
+ it('shows one dialog detail surface, confirms destructive commands, and embeds Data Service settings', () => {
+ const view = props(officialItem);
+ render();
+
+ const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
+ expect(dialog).toHaveTextContent('详细的数据服务介绍。');
+ expect(dialog).toHaveTextContent('读取项目名称');
+ expect(dialog).toHaveTextContent('data-service.documents');
+ expect(dialog).toHaveTextContent('按平台包含');
+ expect(dialog).toHaveTextContent('小明');
+ expect(dialog).toHaveTextContent('插件专属设置');
+ expect(dialog).toHaveTextContent('创建开发数据空间');
+
+ fireEvent.click(screen.getByRole('button', { name: '从当前项目禁用开发数据服务' }));
+ expect(view.onCommand).not.toHaveBeenCalled();
+ expect(screen.getByRole('alertdialog')).toHaveTextContent('Project A');
+ fireEvent.click(screen.getByRole('button', { name: '确认从当前项目禁用' }));
+ expect(view.onCommand).toHaveBeenCalledWith({
+ kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.data-service',
+ });
+ });
+
+ it('dispatches non-destructive detail commands directly and closes from the dialog control', () => {
+ const view = props(officialItem);
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '分配开发数据服务给伙伴' }));
+ expect(view.onCommand).toHaveBeenCalledWith({
+ kind: 'open_agent_assignment', projectId: 'project-a', pluginId: 'makelore.data-service',
+ });
+ fireEvent.click(screen.getByRole('button', { name: '关闭插件详情' }));
+ expect(view.onSelect).toHaveBeenCalledWith(null);
+ });
+
+ it('clears a destructive confirmation when the active project changes', () => {
+ const viewA = props(officialItem);
+ const { rerender } = render();
+
+ fireEvent.click(screen.getByRole('button', { name: '从当前项目禁用开发数据服务' }));
+ expect(screen.getByRole('alertdialog')).toHaveTextContent('Project A');
+
+ const projectBItem: PluginWorkspaceItem = {
+ ...officialItem,
+ commands: [
+ { kind: 'disable_project', projectId: 'project-b', pluginId: 'makelore.data-service' },
+ ],
+ };
+ const viewB = {
+ ...props(projectBItem),
+ activeProjectId: 'project-b',
+ activeProjectName: 'Project B',
+ };
+ rerender();
+
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
+ expect(viewA.onCommand).not.toHaveBeenCalled();
+ expect(viewB.onCommand).not.toHaveBeenCalled();
+ });
+
+ it('closes the detail with Escape and returns focus to the originating card', async () => {
+ function Harness() {
+ const [selected, setSelected] = useState(null);
+ const view = props(selected);
+ view.onSelect = vi.fn((key: PluginWorkspaceItem['key'] | null) => {
+ setSelected(key === officialItem.key ? officialItem : null);
+ });
+ return ;
+ }
+ render();
+ const trigger = screen.getByRole('button', { name: '查看开发数据服务详情' });
+
+ fireEvent.click(trigger);
+ const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
+ expect(dialog).toBeVisible();
+ await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
+ fireEvent.keyDown(dialog, { key: 'Escape' });
+
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: '开发数据服务' })).not.toBeInTheDocument());
+ await waitFor(() => expect(trigger).toHaveFocus());
+ });
+});
diff --git a/tests/unit/project-plugins-page.test.tsx b/tests/unit/project-plugins-page.test.tsx
deleted file mode 100644
index d6ebeff..0000000
--- a/tests/unit/project-plugins-page.test.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import { describe, expect, it, vi } from 'vitest';
-import { ProjectPluginsView } from '@/pages/ProjectPlugins';
-import type { CodingPluginProject } from '@/lib/coding-plugins';
-
-function projection(): CodingPluginProject {
- return {
- schemaVersion: 1,
- project: { localProjectId: 'local-only-id', durableProjectId: '11111111-1111-4111-8111-111111111111' },
- policyStatus: 'current',
- unknownPluginIds: [],
- items: [{
- id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供 JSON 数据。',
- contractVersion: 1, requiresBackend: true,
- enabled: false, state: 'disabled', backend: { status: 'unconfigured' },
- skills: [{ id: 'data-service', assignedAgentIds: ['agent-1'] }],
- capabilities: [
- { id: 'data-service.control', operations: [{
- id: 'inspect',
- billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' },
- tool: {
- name: 'data_service_inspect', label: 'Inspect', description: 'Inspect data',
- mutation: 'read', permissions: ['project.data.read'],
- },
- }] },
- { id: 'data-service.documents', operations: [{
- id: 'put_document',
- billing: { mode: 'external_account', availability: 'available', notice: 'Provider billed' },
- tool: {
- name: 'data_service_put_document', label: 'Put', description: 'Put data',
- mutation: 'write', permissions: ['project.data.write'],
- },
- }] },
- { id: 'data-service.preview', operations: [{
- id: 'get',
- billing: { mode: 'platform_metered', availability: 'unavailable', notice: 'Pricing unavailable' },
- tool: null,
- }] },
- ],
- settingsSurface: 'data-service',
- }],
- };
-}
-
-describe('Project Plugin Center', () => {
- it('uses available/enabled language, text states, exact included copy, and never renders project IDs', () => {
- const onSetEnabled = vi.fn();
- render();
-
- expect(screen.getByRole('heading', { name: '项目插件' })).toHaveClass('text-balance');
- expect(screen.getAllByText('未启用').length).toBeGreaterThan(0);
- expect(screen.getByText('当前包含,不按单次调用扣点')).toBeVisible();
- expect(screen.getByText('小明')).toBeVisible();
- expect(screen.getByText('data-service.control')).toBeVisible();
- expect(screen.getByText('data-service.documents')).toBeVisible();
- expect(screen.getByText('data-service.preview')).toBeVisible();
- expect(screen.getByText('data_service_inspect')).toBeVisible();
- expect(screen.getByText('SDK')).toBeVisible();
- expect(screen.getByText('由外部服务商计费,不计入 Token Point。')).toBeVisible();
- expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).toBeVisible();
- expect(screen.queryByText('local-only-id')).not.toBeInTheDocument();
- expect(screen.queryByText('11111111-1111-4111-8111-111111111111')).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: '启用到项目开发数据服务' }));
- expect(onSetEnabled).toHaveBeenCalledWith('makelore.data-service', true);
- });
-
- it('keeps the last project structure visible when refresh is degraded', () => {
- render();
- expect(screen.getByRole('status')).toHaveTextContent('刷新失败');
- expect(screen.getByRole('article')).toBeVisible();
- });
-
- it('explains unknown billing and keeps invocation unavailable', () => {
- const value = projection();
- value.items[0].state = 'unavailable';
- value.items[0].capabilities = [value.items[0].capabilities[0]];
- value.items[0].capabilities[0].operations[0].billing = { mode: 'platform_metered', availability: 'unavailable', notice: 'pricing unavailable' };
- render();
- expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).toBeVisible();
- expect(screen.getByRole('button', { name: '启用到项目开发数据服务' })).toBeDisabled();
- });
-
- it('shows retained unknown IDs without invented metadata and allows only explicit disable', () => {
- const value = projection();
- value.unknownPluginIds = ['makelore.removed'];
- const onSetEnabled = vi.fn();
- render();
- expect(screen.getByText('配置保留、当前不可用')).toBeVisible();
- expect(screen.getByText('makelore.removed')).toBeVisible();
- expect(screen.queryByText(/makelore.removed.*v\d/u)).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: '从项目禁用保留插件 makelore.removed' }));
- expect(onSetEnabled).toHaveBeenCalledWith('makelore.removed', false);
- });
-});