From b3f4166f21bb135f3c8a0c64bca2d2985b994c38 Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 31 Aug 2026 16:30:17 +0800 Subject: [PATCH] feat: consolidate plugin navigation --- ...-consolidate-plugin-navigation-3d9a6c71.md | 82 +++++++++++++++++++ README.md | 2 +- src/App.tsx | 6 +- src/components/layout/Sidebar.tsx | 34 -------- src/pages/MyPlugins/index.tsx | 14 ++-- src/pages/PluginMarketplace/index.tsx | 44 ++++++++-- src/pages/ProjectConfiguration/index.tsx | 27 ++++-- src/pages/ProjectPlugins/index.tsx | 47 ++++++++--- tests/e2e/plugin-marketplace.spec.ts | 9 +- tests/e2e/project-plugins.spec.ts | 6 +- tests/unit/plugin-marketplace-pages.test.tsx | 15 +++- tests/unit/project-plugins-page.test.tsx | 10 +++ 12 files changed, 227 insertions(+), 69 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md 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 new file mode 100644 index 0000000..b652731 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md @@ -0,0 +1,82 @@ +# Task: Consolidate plugin navigation into project configuration + +## Identity + +- Task ID: 20260831-consolidate-plugin-navigation-3d9a6c71 +- Mode: Feature +- Branch: codex/20260831-consolidate-plugin-navigation-3d9a6c71-consolidate-plugin-navigation +- Worktree: /Users/inmanx/Documents/makelore-consolidate-plugin-navigation-3d9a6c71 +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Completed + +## 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. +- 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. +- 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. +- 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. +- 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. +- 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. +- `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. +- `git diff --check`: passed. + +## Follow-ups + +- Canonical `.project-docs` navigation wording should be promoted only by a later + Integration Gate; no plugin lifecycle, trust, billing, or backend contract changed. + +## Promotion Candidates + +- 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 + 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. +- 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 + approved this consolidation, but Integration Gate ownership is still required. diff --git a/README.md b/README.md index c30e2fd..db2c9b3 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 继续只修改项目选择,不会因获取或下载自动启用或分配。 +- 插件市场 Release A:侧栏只保留一个“插件中心”,其中“发现插件”与“我的插件”两个标签分别承载 Marketplace 浏览获取,以及账号 Library、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason。项目插件入口位于项目配置,与模型、Skill 和知识文件并列;“免费获取”只写入账号 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 b991028..c3967a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,9 +32,7 @@ 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 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 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 +444,9 @@ function App() { )} > } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f7502cc..b123837 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,8 +12,6 @@ import { FolderKanban, LogOut, Plus, - Puzzle, - Library, Store, Settings as SettingsIcon, UserCircle2, @@ -604,21 +602,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi {!sidebarCollapsed ? 插件中心 : null} -
- {activeProject ? ( - - ) : null} ) : isLearningModule ? ( diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx index 34f35af..5e8c856 100644 --- a/src/pages/MyPlugins/index.tsx +++ b/src/pages/MyPlugins/index.tsx @@ -37,6 +37,7 @@ function actionErrorText(error?: string | null): string | null { } export type MyPluginsViewProps = { + embedded?: boolean; library: MarketplaceLibrarySnapshot | null; installations: Record; state: 'idle' | 'loading' | 'ready' | 'error'; @@ -53,9 +54,10 @@ export type MyPluginsViewProps = { export function MyPluginsView(props: MyPluginsViewProps) { const visibleError = actionErrorText(props.error); + const Root = props.embedded ? 'section' : 'main'; return ( -
-

我的插件

账号插件库与本机下载分开管理。下载不会自动启用到项目,也不会分配给伙伴。

+ +
{props.embedded ?

我的插件

:

我的插件

}

账号插件库与本机下载分开管理。下载不会自动启用到项目,也不会分配给伙伴。

{props.library?.stale ?

正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。

: null} {props.state === 'loading' && !props.library ?

正在读取我的插件…

: null} {props.state === 'error' && !props.library ?

无法读取我的插件

{props.error ?? '请稍后重试。'}

: null} @@ -87,17 +89,17 @@ export function MyPluginsView(props: MyPluginsViewProps) { {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? : null} {!removed && plugin.acquisition !== 'system_included' ? : null} {hasDevicePackage ? : null} - + ; })} -
+ ); } -export function MyPlugins() { +export function MyPlugins({ embedded = false }: { embedded?: boolean } = {}) { const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const library = usePluginMarketplaceStore((state) => state.library); @@ -113,5 +115,5 @@ export function MyPlugins() { toast.error(reason instanceof Error ? reason.message : String(reason)); }); const store = pluginMarketplaceStore.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))} />; + 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))} />; } diff --git a/src/pages/PluginMarketplace/index.tsx b/src/pages/PluginMarketplace/index.tsx index f12130e..07c5be3 100644 --- a/src/pages/PluginMarketplace/index.tsx +++ b/src/pages/PluginMarketplace/index.tsx @@ -1,8 +1,11 @@ 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, @@ -20,6 +23,7 @@ const USAGE_COPY: Record = { }; export type PluginMarketplaceViewProps = { + embedded?: boolean; catalog: MarketplaceCatalogPage | null; detail: MarketplacePluginDetail | null; library: MarketplaceLibrarySnapshot | null; @@ -42,12 +46,15 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) { event.preventDefault(); void props.onSearch({ ...(query.trim() ? { query: query.trim() } : {}), ...(category ? { category } : {}), limit: 24 }); }; + const Root = props.embedded ? 'section' : 'main'; return ( -
+
-

插件中心

+ {props.embedded + ?

发现插件

+ :

插件中心

}

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

{props.catalog ?

{props.catalog.total} 个插件

: null} @@ -103,11 +110,13 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) { {props.detail ? : null} -
+ ); } export function PluginMarketplace() { + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get('tab') === 'mine' ? 'mine' : 'discover'; const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const catalog = usePluginMarketplaceStore((state) => state.catalog); @@ -119,11 +128,34 @@ export function PluginMarketplace() { const [selectedId, setSelectedId] = useState(null); useEffect(() => { pluginMarketplaceStore.getState().activateAccount(accountKey); - void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); + if (activeTab === 'discover') void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); - }, [accountKey]); + }, [accountKey, activeTab]); 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))} />; + 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))} /> + + + + + +
; } diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 6dcbdcc..4dbd8ad 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react'; +import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Puzzle, Trash2, Upload, X } from 'lucide-react'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -8,6 +8,7 @@ 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 { 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'; @@ -16,6 +17,7 @@ import { getSkillDisplayInfo } from '@/lib/skill-display'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; import { cn } from '@/lib/utils'; import { codingConversationStore } from '@/stores/coding-conversations'; +import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProjectConfigStore } from '@/stores/project-config'; import { useProviderStore } from '@/stores/providers'; @@ -23,7 +25,7 @@ import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-pro import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; const EMPTY_FILES: string[] = []; -type DrawerMode = 'models' | 'knowledge' | 'skills' | null; +type DrawerMode = 'models' | 'knowledge' | 'skills' | 'plugins' | null; type ProjectIdentityKind = ProjectIdentityChoice['kind']; type SkillStructureEntry = { path: string; type: 'file' | 'directory' }; type SkillInfo = { @@ -141,6 +143,8 @@ export function ProjectConfiguration() { const providerAccounts = useProviderStore((state) => state.accounts); const providerVendors = useProviderStore((state) => state.vendors); const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot); + const pluginProjection = useCodingPluginsStore((state) => state.projection); + const pluginLoadState = useCodingPluginsStore((state) => state.loadState); const [draft, setDraft] = useState(null); const [drawerMode, setDrawerMode] = useState(null); const [activeSkillId, setActiveSkillId] = useState(null); @@ -185,17 +189,29 @@ export function ProjectConfiguration() { setActiveAgentId(requestedAgentId); setPartnerDialogOpen(true); }, [draft, location.search]); + useEffect(() => { + if (new URLSearchParams(location.search).get('resource') === 'plugins') setDrawerMode('plugins'); + }, [location.search]); useEffect(() => { loadSkills(); }, [loadSkills]); useEffect(() => { if (drawerMode === 'skills') loadSkills(); }, [drawerMode, loadSkills]); useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]); + useEffect(() => { + if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined); + }, [activeProject]); const modelOptions = useMemo( () => buildCodingModelOptions(providerAccounts, providerVendors), [providerAccounts, providerVendors], ); const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null; const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null; + const currentPluginProjection = pluginProjection?.project.localProjectId === activeProject?.id ? pluginProjection : null; + const availablePluginCount = currentPluginProjection?.items.length ?? 0; + const enabledPluginCount = currentPluginProjection?.items.filter(({ enabled }) => enabled).length ?? 0; + const pluginSummary = !currentPluginProjection + ? pluginLoadState === 'error' ? '项目插件暂不可用' : '正在读取项目插件' + : `${enabledPluginCount} 个已启用 · ${availablePluginCount} 个可用`; const playSaveConfirmation = () => undefined; const handleBack = () => navigate(-1); @@ -455,8 +471,8 @@ 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')} />
-

项目智能体

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

{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}
+
} 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')} />
+

项目智能体

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

{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}
@@ -486,11 +502,12 @@ export function ProjectConfiguration() { title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'} submitLabel={activeAgent ? '保存智能体' : '创建智能体'} /> - { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> + { if (!open) { 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}
; onRemoveCollection(collection: string): void | Promise; onRemoveProject(): void | Promise; + onManageAgents?(): void; + onResolveIdentity?(): void; }; export function ProjectPluginsView(props: ViewProps) { @@ -69,16 +72,25 @@ export function ProjectPluginsView(props: ViewProps) { 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; + const Root = props.embedded ? 'section' : 'main'; + const manageAgents = () => { + if (props.onManageAgents) props.onManageAgents(); + else navigate('/project-config'); + }; + const resolveIdentity = () => { + if (props.onResolveIdentity) props.onResolveIdentity(); + else navigate('/project-config'); + }; return ( -
-
-

项目插件

管理当前项目可用的首方插件。启用、后端配置和伙伴技能分配是三个独立步骤。

+ +
+ {!props.embedded ?

项目插件

管理当前项目可用的首方插件。启用、后端配置和伙伴技能分配是三个独立步骤。

: null}
{props.projection.policyStatus === 'stale' ?

正在显示上次可用的插件策略;服务恢复前不作为新的计费依据。

: null} {props.refreshError ?

刷新失败,正在保留上次项目插件页面。{props.refreshError}

: null} -
+
{props.projection.items.map((plugin) => (
- {selected.state === 'identity_required' ? : null} - {selected.state === 'authentication_required' ? : null} + {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] ?? '未知伙伴'}) :

尚未分配给任何伙伴。

}

用量与计费

各操作的计费模式已在上方能力列表中逐项显示;平台计量操作的流水仍由权益所有者查看。

@@ -115,11 +127,18 @@ export function ProjectPluginsView(props: ViewProps) {
{props.projection.unknownPluginIds.length ?

配置保留、当前不可用

这些插件 ID 仍保存在项目配置中,但当前账号或设备没有可验证的插件定义。不会自动获取、下载、启用或分配。

    {props.projection.unknownPluginIds.map((pluginId) =>
  • {pluginId}
  • )}
: null} { if (!open) setDisableCandidate(null); }}>禁用{disableCandidate?.displayName}?禁用后,伙伴工具和预览访问会立即停止;云端数据会保留,不会被删除。 -
+ ); } -export function ProjectPlugins() { +export type ProjectPluginsProps = { + embedded?: boolean; + onManageAgents?(): void; + onResolveIdentity?(): void; + onChanged?(): void; +}; + +export function ProjectPlugins({ embedded = false, onManageAgents, onResolveIdentity, onChanged }: ProjectPluginsProps = {}) { const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const activeProject = useCodingWorkspaceStore((state) => state.activeProject); @@ -144,5 +163,13 @@ export function ProjectPlugins() { 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())} />; + const setEnabled = async (pluginId: string, enabled: boolean) => { + try { + await codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled); + onChanged?.(); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } + }; + return safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={setEnabled} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} onManageAgents={onManageAgents} onResolveIdentity={onResolveIdentity} />; } diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index d91fb14..b0c8d0c 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -36,6 +36,8 @@ test.describe('Plugin Marketplace', () => { }); await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + 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.getByRole('heading', { name: '灵感笔记' })).toBeVisible(); await page.getByRole('searchbox', { name: '搜索插件' }).fill('笔记'); @@ -116,6 +118,10 @@ test.describe('Plugin Marketplace', () => { return response; } if (requestPath === '/api/auth/me') return result({ success: true, moduleAccess: { programming: true } }); + 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 === '/api/coding/plugin-marketplace/library') return result(projection()); if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes/beta' && method === 'POST') { installedChannel = 'beta'; @@ -149,7 +155,8 @@ test.describe('Plugin Marketplace', () => { await programmingOption.click(); await expect(page.getByTestId('main-layout')).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByTestId('sidebar-nav-my-plugins').click(); + await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + await page.getByRole('tab', { name: '我的插件' }).click(); await expect(page.getByTestId('my-plugins-page')).toBeVisible(); await expect.poll(async () => app.evaluate(() => ( ((globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []) diff --git a/tests/e2e/project-plugins.spec.ts b/tests/e2e/project-plugins.spec.ts index 1f8d233..2a7e053 100644 --- a/tests/e2e/project-plugins.spec.ts +++ b/tests/e2e/project-plugins.spec.ts @@ -51,8 +51,12 @@ test.describe('Project Plugin Center', () => { }); }); - await page.getByTestId('sidebar-nav-project-plugins').click(); + await page.getByTestId('resource-card-plugins').click(); await expect(page.getByTestId('project-plugins-page')).toBeVisible(); + await page.getByTestId('project-plugins-page').getByRole('button', { name: '刷新' }).click(); + await expect.poll(async () => app.evaluate(() => ( + (globalThis as typeof globalThis & { __projectPluginE2E?: { localProjectId: string } }).__projectPluginE2E?.localProjectId + ))).toBeTruthy(); await expect(page.getByText('当前包含,不按单次调用扣点')).toBeVisible(); const localProjectId = await app.evaluate(() => ( (globalThis as typeof globalThis & { __projectPluginE2E?: { localProjectId: string } }).__projectPluginE2E?.localProjectId diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index 02313d3..5ec7a3a 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -92,7 +92,9 @@ describe('My Plugins', () => { expect(onRemove).toHaveBeenCalledWith('makelore.notes'); fireEvent.click(screen.getByRole('button', { name: '重新免费获取旧插件' })); expect(onReacquire).toHaveBeenCalledWith('makelore.removed'); - expect(screen.getAllByRole('link', { name: '启用到项目' }).length).toBeGreaterThan(0); + const projectLinks = screen.getAllByRole('link', { name: '启用到项目' }); + expect(projectLinks.length).toBeGreaterThan(0); + expect(projectLinks[0]).toHaveAttribute('href', '/project-config?resource=plugins'); expect(screen.getAllByRole('link', { name: '分配给伙伴' }).length).toBeGreaterThan(0); expect(screen.getByText('运行已暂停')).toBeVisible(); expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible(); @@ -129,6 +131,17 @@ describe('My Plugins', () => { expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible(); }); + it('renders as the account/device tab inside the unified plugin hub', () => { + render(); + + expect(screen.getByRole('heading', { name: '我的插件', level: 2 })).toBeVisible(); + expect(screen.getByTestId('my-plugins-page').tagName).toBe('SECTION'); + }); + it('updates an installed Beta package only through the explicit Beta action', () => { const onUpdate = vi.fn(); const onInstallBeta = vi.fn(); diff --git a/tests/unit/project-plugins-page.test.tsx b/tests/unit/project-plugins-page.test.tsx index d6ebeff..f520020 100644 --- a/tests/unit/project-plugins-page.test.tsx +++ b/tests/unit/project-plugins-page.test.tsx @@ -92,4 +92,14 @@ describe('Project Plugin Center', () => { fireEvent.click(screen.getByRole('button', { name: '从项目禁用保留插件 makelore.removed' })); expect(onSetEnabled).toHaveBeenCalledWith('makelore.removed', false); }); + + it('embeds project enablement in configuration and returns to Agent assignment explicitly', () => { + const onManageAgents = vi.fn(); + render(); + + expect(screen.getByTestId('project-plugins-page').tagName).toBe('SECTION'); + expect(screen.queryByRole('heading', { name: '项目插件' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '分配给伙伴' })); + expect(onManageAgents).toHaveBeenCalledTimes(1); + }); });