diff --git a/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md b/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md index b652731..8d6a955 100644 --- a/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md +++ b/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md @@ -12,51 +12,51 @@ ## Scope -- Replace the separate global “插件中心” and “我的插件” navigation entries with one plugin hub that exposes “发现插件” and “我的插件” as tabs. -- Remove the sidebar “项目插件” entry and embed project plugin enablement/configuration in the existing Project Configuration resource area beside models, skills, and knowledge. +- Remove all plugin entries from the programming sidebar and make Project Configuration’s “插件服务” resource the single user-facing plugin surface. +- Combine Marketplace discovery, account/device plugin management, and current-project enablement/configuration as URL-backed “发现插件”, “我的插件”, and “项目启用” tabs. - Preserve account acquisition, device installation, project enablement, Agent skill assignment, runtime authorization, and billing as independent lifecycle states. -- Keep `/my-plugins` and `/project-plugins` as compatibility redirects to the consolidated destinations. +- Keep `/plugin-marketplace`, `/my-plugins`, and `/project-plugins` as compatibility redirects into the appropriate Project Configuration tab. - Update focused unit tests, Electron E2E coverage, and README product-state documentation. ## Intent And Constraints - Reduce navigation duplication without changing Main-owned plugin contracts or `.makelore/project.json` authority. - Reuse the existing light visual system, Radix tabs/sheets, stores, and Host API clients; add no dependency or alternate backend path. -- Keep plugin discovery/account/device management global, while project enablement and Agent assignment remain project-scoped. +- Keep discovery/account/device data global even though its user-facing surface is nested under the active project’s configuration; project enablement and Agent assignment remain project-scoped. - Do not silently acquire, download, enable, assign, authorize, or bill as a side effect of another action. - Preserve unavailable/removed plugin IDs and explicit status messaging rather than inventing metadata or hiding retained configuration. ## Outcome -- Consolidated the two global plugin destinations into one `/plugin-marketplace` - hub. “发现插件” and “我的插件” are URL-backed tabs, and the sidebar now exposes - only the single “插件中心” entry. -- Removed the project plugin sidebar entry and added “项目插件” beside models, - Skills, and knowledge files in Project Configuration. The resource card reports - enabled/available counts and opens the existing project plugin projection in a - wider configuration sheet. +- Removed the remaining global Plugin Center from the programming sidebar. Project + Configuration now exposes one “插件服务” resource beside models, Skills, and + knowledge files. +- Added one wider Plugin Services sheet with URL-backed “发现插件”, “我的插件”, and + “项目启用” tabs. The resource card defaults to current-project enablement, while + compatibility and in-product links can deep-link directly to discovery or the + account/device library. - Reused the existing Marketplace, Library, device installation, project plugin, and Agent Skill stores. Enabling a project plugin refreshes the Skill projection, but does not acquire, download, configure a backend, or assign a Skill. -- Preserved compatibility for saved/deep links: `/my-plugins` redirects to the - “我的插件” tab and `/project-plugins` redirects to the Project Configuration - plugin sheet. My Plugins project actions now target the consolidated routes. -- Kept the standalone view variants available for focused rendering tests while - adding semantic embedded `section` variants for the tab and configuration sheet. +- Preserved compatibility for saved/deep links: `/plugin-marketplace`, + `/my-plugins`, and `/project-plugins` redirect to their corresponding Plugin + Services tab. My Plugins discovery and project actions now target those tabs. +- Reused semantic embedded `section` variants for all three tabs and removed the + routed standalone Plugin Center container. - Updated README product-state copy and focused unit/Electron E2E coverage. ## Verification - Exact `pnpm@10.33.4` was used for repository commands. - `pnpm run typecheck`: passed. -- Focused Vitest: 2 files / 17 tests passed. -- `pnpm test`: 212 files / 1,728 tests passed; 3 existing conditional tests skipped. +- Focused Vitest: 3 files / 19 tests passed. +- `pnpm test`: 213 test files / 1,730 tests passed; 3 existing conditional tests skipped. - `pnpm run lint:check`: passed with zero errors and five pre-existing warnings (`Home/index.tsx` hook dependency and four `Makelore/index.tsx` Fast Refresh warnings). - `pnpm run build:vite`: passed for Renderer, Electron Main, Preload, and utility worker; existing dynamic-import and large-chunk warnings remain non-fatal. -- Focused Electron E2E: 3/3 passed across unified plugin discovery/account-device - management and Project Configuration plugin enablement. +- Focused Electron E2E: 3/3 passed across Plugin Services discovery/account-device + management, sidebar removal, and current-project enablement. - `git diff --check`: passed. ## Follow-ups @@ -68,14 +68,15 @@ - Target canonical documents: `.project-docs/30-worklog/current-state.md` and `.project-docs/20-architecture/module-map.md`. -- Proposal: record that Renderer now exposes a single global Plugin Center with - discovery/account-device tabs, while project enablement/configuration is embedded - in Project Configuration beside models, Skills, and knowledge. -- Evidence: this task's source diff, 17 focused unit tests, the 1,728-test full unit +- Proposal: record that Renderer exposes no standalone plugin sidebar destination; + Project Configuration’s single Plugin Services sheet owns discovery, + account/device management, and current-project enablement tabs beside models, + Skills, and knowledge. +- Evidence: this task's source diff, 19 focused unit tests, the 1,730-test full unit suite, three focused Electron E2E flows, typecheck, lint, and production build. - Future impact: new navigation, documentation, and E2E work should target - `/plugin-marketplace` and `/project-config?resource=plugins`; the legacy routes are - compatibility redirects only. + `/project-config?resource=plugins&pluginTab=discover|mine|project`; all standalone + plugin routes are compatibility redirects only. - Semantic conflicts: none identified. The existing independent lifecycle and Main-owned trust boundaries remain unchanged. - Human confirmation: not required for the factual promotion; the user explicitly diff --git a/README.md b/README.md index db2c9b3..a8ec518 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 浏览获取,以及账号 Library、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason。项目插件入口位于项目配置,与模型、Skill 和知识文件并列;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是彼此独立的动作,不会因获取或下载自动推进。 +- 插件市场 Release A:侧栏不再提供独立插件入口,项目配置中的“插件服务”统一承载“发现插件”“我的插件”“项目启用”三个标签。它们分别管理 Marketplace 浏览获取,账号 Library 与本机安装/更新/删除设备包,以及当前项目启用与配置;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是彼此独立的动作,不会因获取或下载自动推进。旧插件路由仅保留兼容跳转。 - 插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。下一代 Pi worker 使用同一个 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 对话草稿,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 diff --git a/src/App.tsx b/src/App.tsx index c3967a1..e3c3a9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,7 +32,6 @@ const Chat = lazy(() => import('./pages/Chat').then(({ Chat: 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 PluginMarketplace = lazy(() => import('./pages/PluginMarketplace').then(({ PluginMarketplace: 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 }))); @@ -201,6 +200,13 @@ function ModuleSelectionRoute({ return ; } +function LegacyPluginMarketplaceRedirect() { + const location = useLocation(); + const legacyTab = new URLSearchParams(location.search).get('tab'); + const pluginTab = legacyTab === 'mine' ? 'mine' : 'discover'; + return ; +} + function ProjectChatRoute() { const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const loadProjectList = useCodingWorkspaceStore((state) => state.load); @@ -444,9 +450,9 @@ function App() { )} > } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b123837..0f63801 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,7 +12,6 @@ import { FolderKanban, LogOut, Plus, - Store, Settings as SettingsIcon, UserCircle2, } from 'lucide-react'; @@ -586,23 +585,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi {!sidebarCollapsed && '新建项目'} -
- -
: null} + {props.library && props.library.items.length === 0 ?

账号插件库还是空的

前往“发现插件”免费获取需要的插件。

: null}
{props.library?.items.map((plugin) => { const installed = props.installations[plugin.pluginId]; @@ -89,7 +89,7 @@ export function MyPluginsView(props: MyPluginsViewProps) { {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? : null} {!removed && plugin.acquisition !== 'system_included' ? : null} {hasDevicePackage ? : null} - + ; diff --git a/src/pages/PluginMarketplace/index.tsx b/src/pages/PluginMarketplace/index.tsx index 07c5be3..9d832a0 100644 --- a/src/pages/PluginMarketplace/index.tsx +++ b/src/pages/PluginMarketplace/index.tsx @@ -1,11 +1,8 @@ import { FormEvent, useEffect, useMemo, useState } from 'react'; import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react'; -import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { MyPlugins } from '@/pages/MyPlugins'; import type { MarketplaceCatalogPage, MarketplaceCatalogQuery, @@ -54,7 +51,7 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
{props.embedded ?

发现插件

- :

插件中心

} + :

发现插件

}

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

{props.catalog ?

{props.catalog.total} 个插件

: null} @@ -114,9 +111,7 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) { ); } -export function PluginMarketplace() { - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = searchParams.get('tab') === 'mine' ? 'mine' : 'discover'; +export function PluginMarketplaceCatalog({ embedded = false }: { embedded?: boolean } = {}) { const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const catalog = usePluginMarketplaceStore((state) => state.catalog); @@ -128,34 +123,11 @@ export function PluginMarketplace() { const [selectedId, setSelectedId] = useState(null); useEffect(() => { pluginMarketplaceStore.getState().activateAccount(accountKey); - if (activeTab === 'discover') void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); + void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); - }, [accountKey, activeTab]); + }, [accountKey]); const safe = (operation: Promise) => operation.catch((error) => { toast.error(error instanceof Error ? error.message : String(error)); }); - const handleTabChange = (value: string) => { - const next = new URLSearchParams(searchParams); - if (value === 'mine') next.set('tab', 'mine'); - else next.delete('tab'); - setSearchParams(next, { replace: true }); - }; - return
-
-

插件中心

-

在同一处发现插件,并管理账号插件库与本机下载。获取、下载、项目启用和伙伴分配始终是彼此独立的步骤。

-
- - - 发现插件 - 我的插件 - - - safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} /> - - - - - -
; + 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/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 4dbd8ad..739e861 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -7,13 +7,14 @@ import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'; import { AgentCreationDialog, type AgentCreationInput } from '@/components/coding/AgentCreationDialog'; +import { PluginServices } from '@/components/plugins/PluginServices'; import { ProjectPublishAction } from '@/components/works/ProjectPublishAction'; -import { ProjectPlugins } from '@/pages/ProjectPlugins'; import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options'; import { getCodingSkills } from '@/lib/coding-product-tools'; import { abortCodingConversation } from '@/lib/coding-conversations'; import { isCanonicalCodingProjectId } from '@/lib/coding-projects'; import { getSkillDisplayInfo } from '@/lib/skill-display'; +import { getPluginServiceTab, type PluginServiceTab } from '@/lib/plugin-services'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; import { cn } from '@/lib/utils'; import { codingConversationStore } from '@/stores/coding-conversations'; @@ -147,6 +148,7 @@ export function ProjectConfiguration() { const pluginLoadState = useCodingPluginsStore((state) => state.loadState); const [draft, setDraft] = useState(null); const [drawerMode, setDrawerMode] = useState(null); + const [pluginServiceTab, setPluginServiceTab] = useState('project'); const [activeSkillId, setActiveSkillId] = useState(null); const [activeAgentId, setActiveAgentId] = useState(null); const [skills, setSkills] = useState([]); @@ -190,7 +192,10 @@ export function ProjectConfiguration() { setPartnerDialogOpen(true); }, [draft, location.search]); useEffect(() => { - if (new URLSearchParams(location.search).get('resource') === 'plugins') setDrawerMode('plugins'); + const params = new URLSearchParams(location.search); + if (params.get('resource') !== 'plugins') return; + setPluginServiceTab(getPluginServiceTab(params.get('pluginTab'))); + setDrawerMode('plugins'); }, [location.search]); useEffect(() => { loadSkills(); }, [loadSkills]); useEffect(() => { @@ -214,6 +219,29 @@ export function ProjectConfiguration() { : `${enabledPluginCount} 个已启用 · ${availablePluginCount} 个可用`; const playSaveConfirmation = () => undefined; const handleBack = () => navigate(-1); + const handlePluginServiceTabChange = (tab: PluginServiceTab) => { + setPluginServiceTab(tab); + const params = new URLSearchParams(location.search); + params.set('resource', 'plugins'); + params.set('pluginTab', tab); + navigate({ pathname: '/project-config', search: `?${params.toString()}` }, { replace: true }); + }; + const openPluginServices = () => { + setPluginServiceTab('project'); + setDrawerMode('plugins'); + const params = new URLSearchParams(location.search); + params.set('resource', 'plugins'); + params.set('pluginTab', 'project'); + navigate({ pathname: '/project-config', search: `?${params.toString()}` }, { replace: true }); + }; + const closePluginServices = () => { + setDrawerMode(null); + const params = new URLSearchParams(location.search); + params.delete('resource'); + params.delete('pluginTab'); + const search = params.toString(); + navigate({ pathname: '/project-config', search: search ? `?${search}` : '' }, { replace: true }); + }; if (!activeProject) return

一念成光,万物可创。

; if (!draft) return
正在读取项目配置…
; @@ -471,7 +499,7 @@ export function ProjectConfiguration() { )}
-
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />} title="项目插件" subtitle={pluginSummary} onClick={() => setDrawerMode('plugins')} />
+
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />} title="插件服务" subtitle={pluginSummary} onClick={openPluginServices} />

项目智能体

智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。

{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ?
当前项目还没有智能体,点击右上角的 + 创建。
: null}
{draft.agents.some((agent) => agent.archivedAt) ?

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null}
@@ -502,12 +530,12 @@ export function ProjectConfiguration() { title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'} submitLabel={activeAgent ? '保存智能体' : '创建智能体'} /> - { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> + { if (!open) { if (drawerMode === 'plugins') closePluginServices(); else setDrawerMode(null); setActiveSkillId(null); } }}> {drawerMode === 'models' ? setDrawerMode(null)}> : null} {drawerMode === 'knowledge' ? setDrawerMode(null)}> { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} />
{knowledge.length ? knowledge.map((file) =>
{file}
) :
当前还没有知识文件。
}
: null} {drawerMode === 'skills' && activeSkill ? setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"> : null} {drawerMode === 'skills' && !activeSkill ? setDrawerMode(null)}>
{skills.length > 0 ? skills.map((skill) => ) :
当前没有可查看的技能。
}
: null} - {drawerMode === 'plugins' ? setDrawerMode(null)}> setDrawerMode(null)} onManageAgents={() => setDrawerMode(null)} /> : null} + {drawerMode === 'plugins' ? : null}
{ - test('opens the global catalog, searches, and reads detail through fixed Main routes', async ({ launchElectronApp }) => { +test.describe('Project Plugin Services marketplace tabs', () => { + test('opens discovery inside project configuration, searches, and reads detail through fixed Main routes', async ({ launchElectronApp }) => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-marketplace-e2e-')); const app = await launchElectronApp({ skipSetup: true }); try { + await app.evaluate(({ ipcMain }, selectedPath) => { + ipcMain.removeHandler('dialog:open'); + ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] })); + }, projectPath); const page = await getStableWindow(app); await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('sidebar-create-project').click(); + await page.getByRole('button', { name: '选择路径' }).click(); + await page.getByRole('button', { name: '确认创建' }).click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); await app.evaluate(({ ipcMain }) => { const requests: string[] = []; (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E = { requests }; @@ -31,14 +43,25 @@ test.describe('Plugin Marketplace', () => { etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1, } } }; } + if (requestPath.startsWith('/api/coding/plugins')) { + const localProjectId = new URL(requestPath, 'https://makelore.local').searchParams.get('projectId') ?? ''; + return { ok: true, data: { status: 200, ok: true, json: { + schemaVersion: 1, + project: { localProjectId, durableProjectId: '11111111-1111-4111-8111-111111111111' }, + policyStatus: 'current', unknownPluginIds: [], items: [], + } } }; + } return { ok: false, error: { message: `Unexpected Host API request: ${requestPath}` } }; }); }); - await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + await page.getByTestId('resource-card-plugins').click(); + await page.getByRole('tab', { name: '发现插件' }).click(); + await expect(page.getByRole('dialog', { name: '插件服务' })).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(page.getByTestId('plugin-marketplace-page')).toBeVisible(); + await expect(page.getByTestId('plugin-marketplace-discover')).toBeVisible(); await expect(page.getByRole('heading', { name: '灵感笔记' })).toBeVisible(); await page.getByRole('searchbox', { name: '搜索插件' }).fill('笔记'); await page.getByRole('button', { name: '搜索', exact: true }).click(); @@ -56,15 +79,39 @@ test.describe('Plugin Marketplace', () => { expect(JSON.stringify(marketplaceRequests)).not.toMatch(/account|admission|release_id|path=/i); } finally { await closeElectronApp(app); + await rm(projectPath, { recursive: true, force: true }); } }); test('keeps Beta explicit and exposes bounded unavailable, disabled, and device-delete states', async ({ launchElectronApp }) => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-my-plugins-e2e-')); const app = await launchElectronApp({ skipSetup: true }); try { + await app.evaluate(({ ipcMain }, selectedPath) => { + ipcMain.removeHandler('dialog:open'); + ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] })); + }, projectPath); const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-option-programming')).toBeVisible(); - await app.evaluate(({ ipcMain }) => { + await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('sidebar-create-project').click(); + await page.getByRole('button', { name: '选择路径' }).click(); + await page.getByRole('button', { name: '确认创建' }).click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + const cachedProjectResponses = await page.evaluate(async () => { + const list = await window.electron.ipcRenderer.invoke('hostapi:fetch', { + path: '/api/coding/projects', method: 'GET', + }); + const activeProjectId = (list as { data?: { json?: { activeProjectId?: string } } }).data?.json?.activeProjectId ?? ''; + const config = await window.electron.ipcRenderer.invoke('hostapi:fetch', { + path: `/api/coding/projects/config?projectId=${encodeURIComponent(activeProjectId)}`, method: 'GET', + }); + const conversations = await window.electron.ipcRenderer.invoke('hostapi:fetch', { + path: `/api/coding/projects/conversations?projectId=${encodeURIComponent(activeProjectId)}`, method: 'GET', + }); + return { list, config, conversations }; + }); + await app.evaluate(({ ipcMain }, projectResponses: { list: unknown; config: unknown; conversations: unknown }) => { let installedChannel: 'stable' | 'beta' | null = 'stable'; const installedVersion = '1.0.0'; const requests: string[] = []; @@ -118,10 +165,21 @@ test.describe('Plugin Marketplace', () => { return response; } if (requestPath === '/api/auth/me') return result({ success: true, moduleAccess: { programming: true } }); + if (requestPath === '/api/coding/projects') return projectResponses.list; + if (requestPath.startsWith('/api/coding/projects/config')) return projectResponses.config; + if (requestPath.startsWith('/api/coding/projects/conversations')) return projectResponses.conversations; if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({ items: [], nextCursor: null, total: 0, catalogGeneration: 1, etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1, }); + if (requestPath.startsWith('/api/coding/plugins')) { + const localProjectId = new URL(requestPath, 'https://makelore.local').searchParams.get('projectId') ?? ''; + return result({ + schemaVersion: 1, + project: { localProjectId, durableProjectId: '11111111-1111-4111-8111-111111111111' }, + policyStatus: 'current', unknownPluginIds: [], items: [], + }); + } if (requestPath === '/api/coding/plugin-marketplace/library') return result(projection()); if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes/beta' && method === 'POST') { installedChannel = 'beta'; @@ -133,7 +191,7 @@ test.describe('Plugin Marketplace', () => { } return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } }; }); - }); + }, cachedProjectResponses); await page.addInitScript(({ key, value }) => { localStorage.setItem(key, value); }, { @@ -150,13 +208,13 @@ test.describe('Plugin Marketplace', () => { }), }); 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-plugin-marketplace').click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await page.getByTestId('resource-card-plugins').click(); await page.getByRole('tab', { name: '我的插件' }).click(); + await expect(page.getByRole('dialog', { name: '插件服务' })).toBeVisible(); + await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0); await expect(page.getByTestId('my-plugins-page')).toBeVisible(); await expect.poll(async () => app.evaluate(() => ( ((globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []) @@ -184,6 +242,7 @@ test.describe('Plugin Marketplace', () => { expect(requests).toContain('DELETE /api/coding/plugin-marketplace/install/makelore.notes'); } finally { await closeElectronApp(app); + await rm(projectPath, { recursive: true, force: true }); } }); }); diff --git a/tests/e2e/project-plugins.spec.ts b/tests/e2e/project-plugins.spec.ts index 2a7e053..800e4dd 100644 --- a/tests/e2e/project-plugins.spec.ts +++ b/tests/e2e/project-plugins.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; -test.describe('Project Plugin Center', () => { +test.describe('Project Plugin Services', () => { test('enables selection without configuring Data Service or duplicating project IDs', async ({ launchElectronApp }) => { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-plugins-e2e-')); const app = await launchElectronApp({ skipSetup: true }); @@ -52,6 +52,11 @@ test.describe('Project Plugin Center', () => { }); await page.getByTestId('resource-card-plugins').click(); + await expect(page.getByRole('dialog', { name: '插件服务' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '发现插件' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '我的插件' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '项目启用' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0); await expect(page.getByTestId('project-plugins-page')).toBeVisible(); await page.getByTestId('project-plugins-page').getByRole('button', { name: '刷新' }).click(); await expect.poll(async () => app.evaluate(() => ( diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index 5ec7a3a..f03d4b5 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -36,7 +36,7 @@ describe('Plugin Marketplace pages', () => { onSearch={onSearch} onSelect={vi.fn()} onAcquire={onAcquire} />); - expect(screen.getByRole('heading', { name: '插件中心' })).toHaveClass('text-balance'); + 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: '笔记' } }); @@ -94,7 +94,7 @@ describe('My Plugins', () => { expect(onReacquire).toHaveBeenCalledWith('makelore.removed'); const projectLinks = screen.getAllByRole('link', { name: '启用到项目' }); expect(projectLinks.length).toBeGreaterThan(0); - expect(projectLinks[0]).toHaveAttribute('href', '/project-config?resource=plugins'); + expect(projectLinks[0]).toHaveAttribute('href', '/project-config?resource=plugins&pluginTab=project'); expect(screen.getAllByRole('link', { name: '分配给伙伴' }).length).toBeGreaterThan(0); expect(screen.getByText('运行已暂停')).toBeVisible(); expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible(); @@ -131,7 +131,7 @@ describe('My Plugins', () => { expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible(); }); - it('renders as the account/device tab inside the unified plugin hub', () => { + it('renders as the account/device tab inside project plugin services', () => { render( { expect(screen.getByRole('heading', { name: '我的插件', level: 2 })).toBeVisible(); expect(screen.getByTestId('my-plugins-page').tagName).toBe('SECTION'); + expect(screen.getByRole('link', { name: '浏览插件' })).toHaveAttribute('href', '/project-config?resource=plugins&pluginTab=discover'); }); it('updates an installed Beta package only through the explicit Beta action', () => { diff --git a/tests/unit/plugin-services.test.tsx b/tests/unit/plugin-services.test.tsx new file mode 100644 index 0000000..76e0f62 --- /dev/null +++ b/tests/unit/plugin-services.test.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { PluginServices } from '@/components/plugins/PluginServices'; +import { getPluginServiceTab, type PluginServiceTab } from '@/lib/plugin-services'; + +vi.mock('@/pages/PluginMarketplace', () => ({ + PluginMarketplaceCatalog: () =>
发现插件内容
, +})); +vi.mock('@/pages/MyPlugins', () => ({ + MyPlugins: () =>
我的插件内容
, +})); +vi.mock('@/pages/ProjectPlugins', () => ({ + ProjectPlugins: () =>
项目启用内容
, +})); + +function Harness() { + const [value, setValue] = useState('project'); + return ; +} + +describe('PluginServices', () => { + it('keeps discovery, account/device management, and project enablement in one surface', () => { + render(); + + expect(screen.getByRole('tab', { name: '项目启用' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('项目启用内容')).toBeVisible(); + fireEvent.mouseDown(screen.getByRole('tab', { name: '发现插件' }), { button: 0, ctrlKey: false }); + expect(screen.getByText('发现插件内容')).toBeVisible(); + fireEvent.mouseDown(screen.getByRole('tab', { name: '我的插件' }), { button: 0, ctrlKey: false }); + expect(screen.getByText('我的插件内容')).toBeVisible(); + }); + + it('defaults invalid and missing deep-link values to project enablement', () => { + expect(getPluginServiceTab(null)).toBe('project'); + expect(getPluginServiceTab('unknown')).toBe('project'); + expect(getPluginServiceTab('discover')).toBe('discover'); + expect(getPluginServiceTab('mine')).toBe('mine'); + }); +});