Merge branch 'codex/20260831-consolidate-plugin-navigation-3d9a6c71-consolidate-plugin-navigation'
This commit is contained in:
commit
a278ca34b9
15 files changed
+415
-100
No files matched your search
@@ -0,0 +1,83 @@
|
||||
# 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
|
||||
|
||||
- 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 `/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 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
|
||||
|
||||
- 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: `/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: 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 Plugin Services discovery/account-device
|
||||
management, sidebar removal, and current-project 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 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
|
||||
`/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
|
||||
approved this consolidation, but Integration Gate ownership is still required.
|
||||
@@ -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 与本机安装/更新/删除设备包,以及当前项目启用与配置;“免费获取”只写入账号 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`、白色画布与低饱和蓝灰层级。
|
||||
|
||||
+10
-6
@@ -32,9 +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 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 })));
|
||||
@@ -203,6 +200,13 @@ function ModuleSelectionRoute({
|
||||
return <ModuleSelection authRequired={authRequired} />;
|
||||
}
|
||||
|
||||
function LegacyPluginMarketplaceRedirect() {
|
||||
const location = useLocation();
|
||||
const legacyTab = new URLSearchParams(location.search).get('tab');
|
||||
const pluginTab = legacyTab === 'mine' ? 'mine' : 'discover';
|
||||
return <Navigate to={`/project-config?resource=plugins&pluginTab=${pluginTab}`} replace />;
|
||||
}
|
||||
|
||||
function ProjectChatRoute() {
|
||||
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
||||
const loadProjectList = useCodingWorkspaceStore((state) => state.load);
|
||||
@@ -446,9 +450,9 @@ function App() {
|
||||
)}
|
||||
>
|
||||
<Route path="/project-config" element={<ProjectConfiguration />} />
|
||||
<Route path="/project-plugins" element={<ProjectPlugins />} />
|
||||
<Route path="/plugin-marketplace" element={<PluginMarketplace />} />
|
||||
<Route path="/my-plugins" element={<MyPlugins />} />
|
||||
<Route path="/project-plugins" element={<Navigate to="/project-config?resource=plugins&pluginTab=project" replace />} />
|
||||
<Route path="/plugin-marketplace" element={<LegacyPluginMarketplaceRedirect />} />
|
||||
<Route path="/my-plugins" element={<Navigate to="/project-config?resource=plugins&pluginTab=mine" replace />} />
|
||||
<Route path="/makelore-home" element={<Home />} />
|
||||
<Route path="/kangaroo" element={<Navigate to="/project-config" replace />} />
|
||||
<Route path="/subagents" element={<Navigate to="/project-config" replace />} />
|
||||
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
FolderKanban,
|
||||
LogOut,
|
||||
Plus,
|
||||
Puzzle,
|
||||
Library,
|
||||
Store,
|
||||
Settings as SettingsIcon,
|
||||
UserCircle2,
|
||||
} from 'lucide-react';
|
||||
@@ -588,38 +585,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
<Plus className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed && '新建项目'}
|
||||
</button>
|
||||
<div className="mt-3 grid gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-nav-plugin-marketplace"
|
||||
aria-current={location.pathname === '/plugin-marketplace' ? 'page' : undefined}
|
||||
aria-label="插件中心"
|
||||
onClick={() => navigate('/plugin-marketplace')}
|
||||
className={cn(
|
||||
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||||
location.pathname === '/plugin-marketplace' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<Store className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed ? <span>插件中心</span> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-nav-my-plugins"
|
||||
aria-current={location.pathname === '/my-plugins' ? 'page' : undefined}
|
||||
aria-label="我的插件"
|
||||
onClick={() => navigate('/my-plugins')}
|
||||
className={cn(
|
||||
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||||
location.pathname === '/my-plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<Library className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed ? <span>我的插件</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -688,23 +653,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
</DisclosureContent>
|
||||
) : null}
|
||||
</div>
|
||||
{activeProject ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-nav-project-plugins"
|
||||
aria-current={location.pathname === '/project-plugins' ? 'page' : undefined}
|
||||
aria-label="项目插件"
|
||||
onClick={() => navigate('/project-plugins')}
|
||||
className={cn(
|
||||
'motion-press mt-3 flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||||
location.pathname === '/project-plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed ? <span>项目插件</span> : null}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : isLearningModule ? (
|
||||
<LearningSidebar sidebarCollapsed={sidebarCollapsed} />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MyPlugins } from '@/pages/MyPlugins';
|
||||
import { PluginMarketplaceCatalog } from '@/pages/PluginMarketplace';
|
||||
import { ProjectPlugins } from '@/pages/ProjectPlugins';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { getPluginServiceTab, type PluginServiceTab } from '@/lib/plugin-services';
|
||||
|
||||
type PluginServicesProps = {
|
||||
value: PluginServiceTab;
|
||||
onValueChange(value: PluginServiceTab): void;
|
||||
onProjectPluginsChanged(): void;
|
||||
onManageAgents(): void;
|
||||
onResolveIdentity(): void;
|
||||
};
|
||||
|
||||
export function PluginServices({
|
||||
value,
|
||||
onValueChange,
|
||||
onProjectPluginsChanged,
|
||||
onManageAgents,
|
||||
onResolveIdentity,
|
||||
}: PluginServicesProps) {
|
||||
return (
|
||||
<Tabs
|
||||
data-testid="plugin-services"
|
||||
value={value}
|
||||
onValueChange={(next) => onValueChange(getPluginServiceTab(next))}
|
||||
className="min-w-0"
|
||||
>
|
||||
<TabsList aria-label="插件服务内容" className="grid w-full max-w-lg grid-cols-3">
|
||||
<TabsTrigger value="discover">发现插件</TabsTrigger>
|
||||
<TabsTrigger value="mine">我的插件</TabsTrigger>
|
||||
<TabsTrigger value="project">项目启用</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="discover" className="mt-6">
|
||||
<PluginMarketplaceCatalog embedded />
|
||||
</TabsContent>
|
||||
<TabsContent value="mine" className="mt-6">
|
||||
<MyPlugins embedded />
|
||||
</TabsContent>
|
||||
<TabsContent value="project" className="mt-6">
|
||||
<ProjectPlugins
|
||||
embedded
|
||||
onChanged={onProjectPluginsChanged}
|
||||
onManageAgents={onManageAgents}
|
||||
onResolveIdentity={onResolveIdentity}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type PluginServiceTab = 'discover' | 'mine' | 'project';
|
||||
|
||||
export function getPluginServiceTab(value: string | null | undefined): PluginServiceTab {
|
||||
if (value === 'discover' || value === 'mine') return value;
|
||||
return 'project';
|
||||
}
|
||||
@@ -37,6 +37,7 @@ function actionErrorText(error?: string | null): string | null {
|
||||
}
|
||||
|
||||
export type MyPluginsViewProps = {
|
||||
embedded?: boolean;
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
installations: Record<string, MarketplaceInstallation>;
|
||||
state: 'idle' | 'loading' | 'ready' | 'error';
|
||||
@@ -53,14 +54,15 @@ export type MyPluginsViewProps = {
|
||||
|
||||
export function MyPluginsView(props: MyPluginsViewProps) {
|
||||
const visibleError = actionErrorText(props.error);
|
||||
const Root = props.embedded ? 'section' : 'main';
|
||||
return (
|
||||
<main data-testid="my-plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4"><div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">我的插件</h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">账号插件库与本机下载分开管理。下载不会自动启用到项目,也不会分配给伙伴。</p></div><Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}><RefreshCw className="mr-2 h-4 w-4" />刷新</Button></header>
|
||||
<Root data-testid="my-plugins-page" className="w-full text-foreground">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4"><div>{props.embedded ? <h2 className="text-balance text-xl font-semibold tracking-[-0.02em]">我的插件</h2> : <h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">我的插件</h1>}<p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">账号插件库与本机下载分开管理。下载不会自动启用到项目,也不会分配给伙伴。</p></div><Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}><RefreshCw className="mr-2 h-4 w-4" />刷新</Button></header>
|
||||
{props.library?.stale ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。</p> : null}
|
||||
{props.state === 'loading' && !props.library ? <p role="status" className="mt-8 text-sm">正在读取我的插件…</p> : null}
|
||||
{props.state === 'error' && !props.library ? <div role="alert" className="mt-8 rounded-2xl bg-warning/10 p-5"><h2 className="font-semibold">无法读取我的插件</h2><p className="mt-2 text-pretty text-sm">{props.error ?? '请稍后重试。'}</p></div> : null}
|
||||
{visibleError && props.library ? <p role="alert" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">{visibleError}</p> : null}
|
||||
{props.library && props.library.items.length === 0 ? <div className="mt-8 rounded-2xl bg-surface-subtle p-6 text-center"><PackageCheck className="mx-auto h-8 w-8 text-muted-foreground" /><h2 className="mt-3 text-balance font-semibold">账号插件库还是空的</h2><p className="mt-1 text-pretty text-sm text-muted-foreground">前往插件中心免费获取需要的插件。</p><Button asChild className="mt-4 min-h-10"><Link to="/plugin-marketplace">浏览插件中心</Link></Button></div> : null}
|
||||
{props.library && props.library.items.length === 0 ? <div className="mt-8 rounded-2xl bg-surface-subtle p-6 text-center"><PackageCheck className="mx-auto h-8 w-8 text-muted-foreground" /><h2 className="mt-3 text-balance font-semibold">账号插件库还是空的</h2><p className="mt-1 text-pretty text-sm text-muted-foreground">前往“发现插件”免费获取需要的插件。</p><Button asChild className="mt-4 min-h-10"><Link to="/project-config?resource=plugins&pluginTab=discover">浏览插件</Link></Button></div> : null}
|
||||
<section aria-label="账号插件库" className="mt-6 space-y-4">
|
||||
{props.library?.items.map((plugin) => {
|
||||
const installed = props.installations[plugin.pluginId];
|
||||
@@ -87,17 +89,17 @@ export function MyPluginsView(props: MyPluginsViewProps) {
|
||||
{!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy || suspended} aria-label={`安装 Beta${plugin.title}`} onClick={() => void props.onInstallBeta(plugin.pluginId)}>安装 Beta</Button> : null}
|
||||
{!removed && plugin.acquisition !== 'system_included' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy} aria-label={`移除${plugin.title}`} onClick={() => void props.onRemove(plugin.pluginId)}><Trash2 className="mr-2 h-4 w-4" />移除</Button> : null}
|
||||
{hasDevicePackage ? <Button type="button" variant="ghost" className="min-h-10" disabled={busy} aria-label={`删除设备上的${plugin.title}`} onClick={() => void props.onUninstall(plugin.pluginId)}>删除设备包</Button> : null}
|
||||
<Button asChild variant="outline" className="min-h-10"><Link to="/project-plugins">启用到项目</Link></Button>
|
||||
<Button asChild variant="outline" className="min-h-10"><Link to="/project-config?resource=plugins&pluginTab=project">启用到项目</Link></Button>
|
||||
<Button asChild variant="outline" className="min-h-10"><Link to="/project-config">分配给伙伴</Link></Button>
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
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 <MyPluginsView library={library} installations={installations} state={state} error={error} pending={pending} onRefresh={() => 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 <MyPluginsView embedded={embedded} library={library} installations={installations} state={state} error={error} pending={pending} onRefresh={() => 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))} />;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const USAGE_COPY: Record<MarketplaceUsageBilling, string> = {
|
||||
};
|
||||
|
||||
export type PluginMarketplaceViewProps = {
|
||||
embedded?: boolean;
|
||||
catalog: MarketplaceCatalogPage | null;
|
||||
detail: MarketplacePluginDetail | null;
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
@@ -42,12 +43,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 (
|
||||
<main data-testid="plugin-marketplace-page" className="mx-auto w-full max-w-7xl text-foreground">
|
||||
<Root data-testid={props.embedded ? 'plugin-marketplace-discover' : 'plugin-marketplace-page'} className="w-full text-foreground">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">插件中心</h1>
|
||||
{props.embedded
|
||||
? <h2 className="text-balance text-xl font-semibold tracking-[-0.02em]">发现插件</h2>
|
||||
: <h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">发现插件</h1>}
|
||||
<p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">浏览 MakeLore 运营发行的插件。免费获取只加入账号插件库,不会自动下载、启用项目或分配给伙伴。</p>
|
||||
</div>
|
||||
{props.catalog ? <p className="tabular-nums rounded-xl bg-surface-subtle px-3 py-2 text-sm" aria-label={`共 ${props.catalog.total} 个插件`}><span className="tabular-nums">{props.catalog.total}</span> 个插件</p> : null}
|
||||
@@ -103,11 +107,11 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
|
||||
</section>
|
||||
|
||||
{props.detail ? <aside aria-label={`${props.detail.title}详情`} className="mt-6 rounded-2xl bg-surface-subtle p-5 sm:p-6"><div className="flex flex-wrap items-start justify-between gap-3"><div><p className="text-sm text-muted-foreground">插件详情</p><h2 className="mt-1 text-balance text-2xl font-semibold">{props.detail.title}</h2></div><Badge variant="outline"><ShieldCheck className="mr-1 h-3.5 w-3.5" />{props.detail.providerDisplayName}</Badge></div><p className="mt-3 whitespace-pre-wrap text-pretty text-sm">{props.detail.descriptionMarkdown}</p><h3 className="mt-5 text-balance font-semibold">权限</h3>{props.detail.permissions.length ? <ul className="mt-2 list-inside list-disc space-y-1 text-sm">{props.detail.permissions.map((permission) => <li key={permission} className="text-pretty">{permission}</li>)}</ul> : <p className="mt-2 text-sm text-muted-foreground">不请求额外权限。</p>}</aside> : null}
|
||||
</main>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginMarketplace() {
|
||||
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);
|
||||
@@ -125,5 +129,5 @@ export function PluginMarketplace() {
|
||||
const safe = (operation: Promise<void>) => operation.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
return <PluginMarketplaceView catalog={catalog} detail={selectedId ? details[selectedId] ?? null : null} library={library} catalogState={catalogState} catalogError={catalogError} pending={pending} onSearch={(input) => safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />;
|
||||
return <PluginMarketplaceView embedded={embedded} catalog={catalog} detail={selectedId ? details[selectedId] ?? null : null} library={library} catalogState={catalogState} catalogError={catalogError} pending={pending} onSearch={(input) => safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />;
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
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';
|
||||
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 { 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';
|
||||
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 +26,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,8 +144,11 @@ 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<CodingProjectConfig | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
|
||||
const [pluginServiceTab, setPluginServiceTab] = useState<PluginServiceTab>('project');
|
||||
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
@@ -185,19 +191,57 @@ export function ProjectConfiguration() {
|
||||
setActiveAgentId(requestedAgentId);
|
||||
setPartnerDialogOpen(true);
|
||||
}, [draft, location.search]);
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
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);
|
||||
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 <main data-testid="project-configuration-empty-state" className="flex min-h-full items-start bg-background p-8 text-foreground"><p className="pt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p></main>;
|
||||
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
|
||||
@@ -455,8 +499,8 @@ export function ProjectConfiguration() {
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
<section className="grid gap-3 md:grid-cols-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /></section>
|
||||
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">项目智能体</h2><p className="mt-1 text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建智能体" title="创建智能体" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型设置中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">当前项目还没有智能体,点击右上角的 + 创建。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}</section>
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件服务" subtitle={pluginSummary} onClick={openPluginServices} /></section>
|
||||
<section id="project-agents-section" className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">项目智能体</h2><p className="mt-1 text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建智能体" title="创建智能体" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型设置中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">当前项目还没有智能体,点击右上角的 + 创建。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}</section>
|
||||
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
|
||||
<div data-testid="project-configuration-primary-actions" className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-start sm:justify-end">
|
||||
@@ -486,11 +530,12 @@ export function ProjectConfiguration() {
|
||||
title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'}
|
||||
submitLabel={activeAgent ? '保存智能体' : '创建智能体'}
|
||||
/>
|
||||
<Sheet open={drawerMode !== null} onOpenChange={(open) => { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}><SheetContent className="glass-surface flex w-[min(430px,94vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none">
|
||||
<Sheet open={drawerMode !== null} onOpenChange={(open) => { if (!open) { if (drawerMode === 'plugins') closePluginServices(); else setDrawerMode(null); setActiveSkillId(null); } }}><SheetContent className={cn('glass-surface flex flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none', drawerMode === 'plugins' ? 'w-[min(1080px,96vw)]' : 'w-[min(430px,94vw)]')}>
|
||||
{drawerMode === 'models' ? <DrawerFrame title="可用模型" desc="查看已配置的模型;智能体默认模型在智能体配置中设置。" onClose={() => setDrawerMode(null)}><ModelList models={modelOptions} /></DrawerFrame> : null}
|
||||
{drawerMode === 'knowledge' ? <DrawerFrame title="知识文件" desc="文件保存在当前项目的 knowledge/ 目录中。" onClose={() => setDrawerMode(null)}><input ref={knowledgeInputRef} type="file" className="sr-only" onChange={(event) => { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} /><Button className="w-full border border-foreground/15 bg-brand-soft text-foreground" onClick={() => knowledgeInputRef.current?.click()}><Upload className="mr-2 h-4 w-4" />上传知识文件</Button><div className="mt-5 space-y-3">{knowledge.length ? knowledge.map((file) => <div key={file} className="rounded-lg border border-foreground/15 bg-white p-3 font-semibold">{file}</div>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium">当前还没有知识文件。</div>}</div></DrawerFrame> : null}
|
||||
{drawerMode === 'skills' && activeSkill ? <DrawerFrame title={`${activeSkill.name} · 技能详情`} desc="查看技能目录结构和主文件 SKILL.md。" onClose={() => setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"><SkillDetail skill={activeSkill} /></DrawerFrame> : null}
|
||||
{drawerMode === 'skills' && !activeSkill ? <DrawerFrame title="可用技能" desc="查看技能目录与主文件;在智能体配置中启用所需技能。" onClose={() => setDrawerMode(null)}><div className="space-y-3">{skills.length > 0 ? skills.map((skill) => <button key={skill.id} type="button" data-testid={`skill-card-${skill.id}`} aria-label={`查看技能详情:${skill.name}`} onClick={() => setActiveSkillId(skill.id)} className="w-full rounded-lg border border-foreground/15 bg-white p-3 text-left transition hover:border-brand/40 hover:bg-brand-soft/30"><div className="flex items-start gap-3"><Blocks className="mt-0.5 h-4 w-4 shrink-0 text-brand" /><span className="min-w-0 flex-1"><span className="block font-semibold">{skill.name}</span><span className="mt-1 block text-sm text-muted-foreground">{skill.description}</span><span className="mt-2 block text-[11px] font-medium text-brand">查看目录与 SKILL.md</span></span><ArrowLeft className="mt-0.5 h-4 w-4 shrink-0 rotate-180 text-muted-foreground" aria-hidden="true" /></div></button>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">当前没有可查看的技能。</div>}</div></DrawerFrame> : null}
|
||||
{drawerMode === 'plugins' ? <DrawerFrame title="插件服务" desc="发现与下载插件,并为当前项目单独启用;伙伴 Skill 仍在智能体配置中分配。" onClose={closePluginServices}><PluginServices value={pluginServiceTab} onValueChange={handlePluginServiceTabChange} onProjectPluginsChanged={loadSkills} onResolveIdentity={closePluginServices} onManageAgents={closePluginServices} /></DrawerFrame> : null}
|
||||
</SheetContent></Sheet>
|
||||
<ConfirmDialog
|
||||
open={Boolean(archiveAgentId)}
|
||||
|
||||
@@ -42,6 +42,7 @@ function billingText(billing: PluginBilling): string {
|
||||
}
|
||||
|
||||
type ViewProps = {
|
||||
embedded?: boolean;
|
||||
projectName: string;
|
||||
projection: CodingPluginProject;
|
||||
dataService: DataServiceInstanceState | null;
|
||||
@@ -56,6 +57,8 @@ type ViewProps = {
|
||||
onReset(): void | Promise<void>;
|
||||
onRemoveCollection(collection: string): void | Promise<void>;
|
||||
onRemoveProject(): void | Promise<void>;
|
||||
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 (
|
||||
<main data-testid="project-plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">项目插件</h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">管理当前项目可用的首方插件。启用、后端配置和伙伴技能分配是三个独立步骤。</p></div>
|
||||
<Root data-testid="project-plugins-page" className={cn('w-full text-foreground', !props.embedded && 'mx-auto max-w-7xl')}>
|
||||
<header className={cn('flex flex-wrap items-start justify-between gap-4', props.embedded && 'justify-end')}>
|
||||
{!props.embedded ? <div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]">项目插件</h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">管理当前项目可用的首方插件。启用、后端配置和伙伴技能分配是三个独立步骤。</p></div> : null}
|
||||
<Button type="button" variant="outline" onClick={() => void props.onRefresh()} disabled={Boolean(props.pending[`load:${props.projection.project.localProjectId}`])}><RefreshCw className="mr-2 h-4 w-4" />刷新</Button>
|
||||
</header>
|
||||
{props.projection.policyStatus === 'stale' ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">正在显示上次可用的插件策略;服务恢复前不作为新的计费依据。</p> : null}
|
||||
{props.refreshError ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">刷新失败,正在保留上次项目插件页面。{props.refreshError}</p> : null}
|
||||
<div className="mt-6 grid gap-5 lg:grid-cols-[minmax(240px,0.75fr)_minmax(0,1.75fr)]">
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-[minmax(220px,0.7fr)_minmax(0,1.8fr)]">
|
||||
<section aria-label="可用插件" className="space-y-3">
|
||||
{props.projection.items.map((plugin) => (
|
||||
<button key={plugin.id} type="button" onClick={() => setSelectedId(plugin.id)} className={cn('motion-press flex min-h-10 w-full items-center gap-3 rounded-2xl bg-background p-4 text-left shadow-soft ring-1 ring-border/70 transition-[box-shadow,background-color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35', selected?.id === plugin.id && 'bg-brand-soft ring-brand/25')}>
|
||||
@@ -100,12 +112,12 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">合同版本</p><p className="mt-1 font-semibold">v{selected.contractVersion}</p></div>
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">后端</p><p className="mt-1 font-semibold">{selected.requiresBackend ? '需要' : '不需要'}</p></div>
|
||||
</section>
|
||||
{selected.state === 'identity_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/project-config')}>创建或绑定项目 ID</Button> : null}
|
||||
{selected.state === 'authentication_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/login', { state: { from: '/project-plugins' } })}>前往登录</Button> : null}
|
||||
{selected.state === 'identity_required' ? <Button type="button" variant="outline" className="mt-5" onClick={resolveIdentity}>创建或绑定项目 ID</Button> : null}
|
||||
{selected.state === 'authentication_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/login', { state: { from: '/project-config?resource=plugins' } })}>前往登录</Button> : null}
|
||||
<section className="mt-7" aria-labelledby="plugin-capabilities-heading"><h3 id="plugin-capabilities-heading" className="text-balance text-lg font-semibold">能力与权限</h3>
|
||||
<div className="mt-3 space-y-4">{selected.capabilities.map((capability) => <div key={capability.id} className="rounded-xl border border-border/70 p-4"><h4 className="font-mono text-sm font-semibold">{capability.id}</h4><div className="mt-3 space-y-3">{capability.operations.map((operation) => <div key={operation.id} className="rounded-lg bg-surface-subtle p-3"><div className="flex flex-wrap items-center gap-2"><code className="font-mono text-sm font-semibold">{operation.id}</code>{operation.tool ? <><Badge variant="outline">{MUTATION_TEXT[operation.tool.mutation]}</Badge><code className="text-xs text-muted-foreground">{operation.tool.name}</code></> : <Badge variant="outline">SDK</Badge>}</div>{operation.tool ? <><p className="mt-2 text-pretty text-sm text-muted-foreground">{operation.tool.description}</p><p className="mt-2 flex items-center gap-2 text-xs"><ShieldCheck className="h-4 w-4 text-brand" />{operation.tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}</p></> : <p className="mt-2 text-pretty text-sm text-muted-foreground">由生成的 Data Service SDK 使用,不作为 Pi 工具暴露。</p>}<p className="mt-2 text-pretty text-xs font-medium">{billingText(operation.billing)}</p></div>)}</div></div>)}</div>
|
||||
</section>
|
||||
<section className="mt-7" aria-labelledby="plugin-agents-heading"><div className="flex flex-wrap items-center justify-between gap-3"><h3 id="plugin-agents-heading" className="text-balance text-lg font-semibold">伙伴分配</h3><Button type="button" variant="outline" className="min-h-10" onClick={() => navigate('/project-config')}>分配给伙伴</Button></div>
|
||||
<section className="mt-7" aria-labelledby="plugin-agents-heading"><div className="flex flex-wrap items-center justify-between gap-3"><h3 id="plugin-agents-heading" className="text-balance text-lg font-semibold">伙伴分配</h3><Button type="button" variant="outline" className="min-h-10" onClick={manageAgents}>分配给伙伴</Button></div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => <Badge key={id} variant="secondary">{props.agentNames[id] ?? '未知伙伴'}</Badge>) : <p className="text-pretty text-sm text-muted-foreground">尚未分配给任何伙伴。</p>}</div>
|
||||
</section>
|
||||
<section className="mt-7" aria-labelledby="plugin-billing-heading"><h3 id="plugin-billing-heading" className="text-balance text-lg font-semibold">用量与计费</h3><p className="mt-2 text-pretty text-sm">各操作的计费模式已在上方能力列表中逐项显示;平台计量操作的流水仍由权益所有者查看。</p></section>
|
||||
@@ -115,11 +127,18 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
</div>
|
||||
{props.projection.unknownPluginIds.length ? <section aria-labelledby="retained-plugin-ids-heading" className="mt-6 rounded-2xl bg-warning/10 p-5"><h2 id="retained-plugin-ids-heading" className="text-balance text-lg font-semibold">配置保留、当前不可用</h2><p className="mt-2 text-pretty text-sm text-muted-foreground">这些插件 ID 仍保存在项目配置中,但当前账号或设备没有可验证的插件定义。不会自动获取、下载、启用或分配。</p><ul className="mt-3 space-y-2">{props.projection.unknownPluginIds.map((pluginId) => <li key={pluginId} className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-background p-3 ring-1 ring-border/70"><code className="break-all text-sm">{pluginId}</code><Button type="button" variant="outline" className="min-h-10" aria-label={`从项目禁用保留插件 ${pluginId}`} onClick={() => void props.onSetEnabled(pluginId, false)}>从项目禁用</Button></li>)}</ul></section> : null}
|
||||
<Dialog open={Boolean(disableCandidate)} onOpenChange={(open) => { if (!open) setDisableCandidate(null); }}><DialogContent><DialogHeader><DialogTitle className="text-balance">禁用{disableCandidate?.displayName}?</DialogTitle><DialogDescription className="text-pretty">禁用后,伙伴工具和预览访问会立即停止;云端数据会保留,不会被删除。</DialogDescription></DialogHeader><DialogFooter><Button type="button" variant="outline" onClick={() => setDisableCandidate(null)}>取消</Button><Button type="button" onClick={() => { if (disableCandidate) void props.onSetEnabled(disableCandidate.id, false); setDisableCandidate(null); }}>确认禁用并保留数据</Button></DialogFooter></DialogContent></Dialog>
|
||||
</main>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>) => promise.catch((reason) => {
|
||||
toast.error(reason instanceof Error ? reason.message : String(reason));
|
||||
});
|
||||
return <ProjectPluginsView projectName={activeProject.name} projection={currentProjection} dataService={dataService} pending={pending} agentNames={agentNames} refreshError={loadState === 'error' ? error : null} marketplaceLibrary={marketplaceLibrary} installations={installations} onRefresh={() => 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 <ProjectPluginsView embedded={embedded} projectName={activeProject.name} projection={currentProjection} dataService={dataService} pending={pending} agentNames={agentNames} refreshError={loadState === 'error' ? error : null} marketplaceLibrary={marketplaceLibrary} installations={installations} onRefresh={() => 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} />;
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
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('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,12 +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 expect(page.getByTestId('plugin-marketplace-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('sidebar-nav-my-plugins')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-nav-project-plugins')).toHaveCount(0);
|
||||
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();
|
||||
@@ -54,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[] = [];
|
||||
@@ -116,6 +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';
|
||||
@@ -127,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);
|
||||
}, {
|
||||
@@ -144,12 +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-my-plugins').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 ?? [])
|
||||
@@ -177,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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -51,8 +51,17 @@ test.describe('Project Plugin Center', () => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByTestId('sidebar-nav-project-plugins').click();
|
||||
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(() => (
|
||||
(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
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('Plugin Marketplace pages', () => {
|
||||
onSearch={onSearch} onSelect={vi.fn()} onAcquire={onAcquire}
|
||||
/></MemoryRouter>);
|
||||
|
||||
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: '笔记' } });
|
||||
@@ -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&pluginTab=project');
|
||||
expect(screen.getAllByRole('link', { name: '分配给伙伴' }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('运行已暂停')).toBeVisible();
|
||||
expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible();
|
||||
@@ -129,6 +131,18 @@ describe('My Plugins', () => {
|
||||
expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders as the account/device tab inside project plugin services', () => {
|
||||
render(<MemoryRouter><MyPluginsView
|
||||
embedded library={{ ...library, items: [] }} installations={{}} state="ready"
|
||||
pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
|
||||
onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
|
||||
/></MemoryRouter>);
|
||||
|
||||
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', () => {
|
||||
const onUpdate = vi.fn();
|
||||
const onInstallBeta = vi.fn();
|
||||
|
||||
@@ -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: () => <div>发现插件内容</div>,
|
||||
}));
|
||||
vi.mock('@/pages/MyPlugins', () => ({
|
||||
MyPlugins: () => <div>我的插件内容</div>,
|
||||
}));
|
||||
vi.mock('@/pages/ProjectPlugins', () => ({
|
||||
ProjectPlugins: () => <div>项目启用内容</div>,
|
||||
}));
|
||||
|
||||
function Harness() {
|
||||
const [value, setValue] = useState<PluginServiceTab>('project');
|
||||
return <PluginServices
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onProjectPluginsChanged={vi.fn()}
|
||||
onManageAgents={vi.fn()}
|
||||
onResolveIdentity={vi.fn()}
|
||||
/>;
|
||||
}
|
||||
|
||||
describe('PluginServices', () => {
|
||||
it('keeps discovery, account/device management, and project enablement in one surface', () => {
|
||||
render(<MemoryRouter><Harness /></MemoryRouter>);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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(<MemoryRouter><ProjectPluginsView embedded projectName="Demo" projection={projection()} dataService={null} pending={{}} agentNames={{}} onRefresh={vi.fn()} onSetEnabled={vi.fn()} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} onManageAgents={onManageAgents} /></MemoryRouter>);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user