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 ( -
-

我的插件

官方插件与这台设备上通过对话安装的 Skill、Pi extension 分开管理。本机包启用后会自动载入新建的主伙伴会话。

-

官方插件

- {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 ( -
-
-
-

插件中心

-

浏览 MakeLore 运营发行的插件。免费获取只加入账号插件库,不会自动下载、启用项目或分配给伙伴。

-
- {props.catalog ?

{props.catalog.total} 个插件

: null} -
- -
- - - -
- - {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 ( + <> + { if (!open) props.onClose(); }}> + +
+ +
+ {SOURCE_TEXT[props.item.source]} + {props.item.stale ? 缓存 : null} + {props.item.unavailable ? 暂不可用 : null} +
+ {props.item.title} + + {props.item.publisher ? `${props.item.publisher} · ` : ''} + {props.item.version ? `版本 ${props.item.version}` : '版本未知'} + +
+ +
+ +
+
+

介绍

+

+ {detail?.descriptionMarkdown || props.item.summary || '暂无介绍。'} +

+
+ +
+

账号、设备与项目状态

+
+
+
交付
+
{DELIVERY_TEXT[props.item.delivery]}
+
+
+
当前设备
+
+ {props.item.source === 'local' + ? (props.item.localEnabled ? '本机全局已启用' : '本机全局已停用') + : installation?.version ? `官方包 ${installation.version}` : props.item.delivery === 'system_included' ? '随应用提供' : '未下载官方包'} +
+
+
+
当前项目
+
{PROJECT_TEXT[props.item.projectState]}
+
+
+
+ +
+

可用操作

+ {props.item.commands.length ? ( +
+ {props.item.commands.map((command) => ( + + ))} +
+ ) :

当前状态没有可执行操作。

} +
+ +
+

能力与权限

+ {project?.capabilities.length ? ( +
+ {project.capabilities.map((capability) => ( +
+

{capability.id}

+
    + {capability.operations.map((operation) => ( +
  • +
    + {operation.id} + {operation.tool ? {operation.tool.mutation} : SDK} +
    + {operation.tool ?

    {operation.tool.description}

    : null} +

    {operation.billing.notice}

    +
  • + ))} +
+
+ ))} +
+ ) : props.item.local ? ( +
+

{props.item.local.kind === 'skill-only' ? 'Skill' : props.item.local.kind === 'pi-extension' ? 'Pi extension' : 'Skill + Pi extension'}

+

{props.item.local.skillEntries.length} 个 Skill · {props.item.local.extensionEntries.length} 个 extension

+ {props.item.local.confirmedExecutableCode ?

包含可执行 Pi extension,运行时拥有完整桌面权限。

: null} +
+ ) :

当前没有可显示的能力投影。

} + {detail?.permissions.length ? ( +
+ +

{detail.permissions.join('、')}

+
+ ) : null} +
+ +
+

Token Point 与计费

+

{billingText(props.item)}

+
+ +
+

伙伴分配

+ {props.item.source === 'local' + ?

本机包全局生效,不作为项目 Agent Skill 分配项;符合条件的新建或空闲主伙伴及其 worker 会按既有生命周期加载。

+ : props.item.assignedAgentNames.length + ?
{props.item.assignedAgentNames.map((name) => {name})}
+ :

尚未分配给伙伴。

} +
+ + {showDataService ? ( +
+

插件专属设置

+ +
+ ) : null} +
+
+
+ + { if (!open) setConfirmation(null); }}> + + + {confirmCopy?.title} + {confirmCopy?.description} + + + + + + + + + ); +} 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 ( +
+
+
+

MakeLore Code

+

插件

+

+ MakeLore 运营发布官方插件;本机 Skill 和 Pi extension 仅通过对话安装。获取、项目启用与伙伴分配彼此独立,本页集中展示账号、设备、当前项目与伙伴状态。 +

+
+ +
+ +
+
+ + + +
+ +
+
+ + +
+ +
+
+
+
+ + {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 ; -} - -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} -
-

发布者

MakeLore

-

包版本

{selected.version}

-

合同版本

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} - { if (!open) setDisableCandidate(null); }}>禁用{disableCandidate?.displayName}?禁用后,伙伴工具和预览访问会立即停止;云端数据会保留,不会被删除。 -
- ); -} - -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 ( +
+ {location.pathname}{location.search} + +
+ ); +} + +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); - }); -});