feat: add plugin marketplace renderer
This commit is contained in:
@@ -33,6 +33,8 @@ const Home = lazy(() => import('./pages/Home').then(({ Home: 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 })));
|
||||
@@ -455,6 +457,8 @@ 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="/makelore-home" element={<Home />} />
|
||||
<Route path="/kangaroo" element={<Navigate to="/project-config" replace />} />
|
||||
<Route path="/subagents" element={<Navigate to="/project-config" replace />} />
|
||||
|
||||
@@ -39,6 +39,8 @@ export function MainLayout() {
|
||||
const isChatWorkspace = location.pathname === '/chat';
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config'
|
||||
|| location.pathname === '/project-plugins'
|
||||
|| location.pathname === '/plugin-marketplace'
|
||||
|| location.pathname === '/my-plugins'
|
||||
|| !isProgrammingModule;
|
||||
|
||||
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
LogOut,
|
||||
Plus,
|
||||
Puzzle,
|
||||
Library,
|
||||
Store,
|
||||
Settings as SettingsIcon,
|
||||
UserCircle2,
|
||||
} from 'lucide-react';
|
||||
@@ -586,6 +588,38 @@ 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"
|
||||
|
||||
@@ -65,6 +65,8 @@ const moduleAccessKeyById: Record<AiModuleId, ModuleAccessKey> = {
|
||||
};
|
||||
|
||||
const PROGRAMMING_ROUTE_PREFIXES = [
|
||||
'/plugin-marketplace',
|
||||
'/my-plugins',
|
||||
'/project-config',
|
||||
'/project-plugins',
|
||||
'/makelore-home',
|
||||
@@ -77,7 +79,15 @@ const PROGRAMMING_ROUTE_PREFIXES = [
|
||||
] as const;
|
||||
|
||||
const PROGRAMMING_PROVIDER_ROUTE_PREFIXES = [
|
||||
...PROGRAMMING_ROUTE_PREFIXES,
|
||||
'/project-config',
|
||||
'/project-plugins',
|
||||
'/makelore-home',
|
||||
'/kangaroo',
|
||||
'/subagents',
|
||||
'/chat',
|
||||
'/deliverables',
|
||||
'/workbench',
|
||||
'/models',
|
||||
'/settings',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export type CodingPluginProject = {
|
||||
schemaVersion: 1;
|
||||
project: { localProjectId: string; durableProjectId: string | null };
|
||||
policyStatus: PluginPolicyStatus;
|
||||
unknownPluginIds: string[];
|
||||
items: CodingPluginItem[];
|
||||
};
|
||||
|
||||
@@ -181,8 +182,8 @@ function pluginItem(value: unknown): CodingPluginItem {
|
||||
|
||||
export function parseCodingPluginProject(value: unknown): CodingPluginProject {
|
||||
const source = record(value, 'plugin project');
|
||||
exact(source, ['schemaVersion', 'project', 'policyStatus', 'items'], 'plugin project');
|
||||
if (source.schemaVersion !== 1 || !Array.isArray(source.items)) throw new Error('plugin project schema is invalid');
|
||||
exact(source, ['schemaVersion', 'project', 'policyStatus', 'unknownPluginIds', 'items'], 'plugin project');
|
||||
if (source.schemaVersion !== 1 || !Array.isArray(source.items) || !Array.isArray(source.unknownPluginIds)) throw new Error('plugin project schema is invalid');
|
||||
const project = record(source.project, 'project'); exact(project, ['localProjectId', 'durableProjectId'], 'project');
|
||||
const policyStatus = string(source.policyStatus, 'policyStatus', 16) as PluginPolicyStatus;
|
||||
if (!['current', 'stale', 'unavailable'].includes(policyStatus)) throw new Error('policyStatus is invalid');
|
||||
@@ -190,6 +191,7 @@ export function parseCodingPluginProject(value: unknown): CodingPluginProject {
|
||||
schemaVersion: 1,
|
||||
project: { localProjectId: string(project.localProjectId, 'project.localProjectId', 128), durableProjectId: project.durableProjectId === null ? null : string(project.durableProjectId, 'project.durableProjectId', 128) },
|
||||
policyStatus,
|
||||
unknownPluginIds: source.unknownPluginIds.map((id) => string(id, 'unknown plugin id', 128)),
|
||||
items: source.items.map(pluginItem),
|
||||
};
|
||||
}
|
||||
|
||||
290
src/lib/plugin-marketplace.ts
Normal file
290
src/lib/plugin-marketplace.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
|
||||
export type MarketplaceUsageBilling = 'included' | 'token_point' | 'mixed';
|
||||
export type MarketplaceAcquisition = 'free' | 'system_included';
|
||||
|
||||
export type MarketplaceCatalogItem = {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
providerDisplayName: string;
|
||||
runtimeKind: 'skill_only' | 'bundled_typed' | 'platform_hosted';
|
||||
runtimeStatus: 'enabled' | 'suspended';
|
||||
acquisition: MarketplaceAcquisition;
|
||||
usageBilling: MarketplaceUsageBilling;
|
||||
includedOperationCount: number;
|
||||
meteredOperationCount: number;
|
||||
stableVersion: string | null;
|
||||
betaVersion: string | null;
|
||||
};
|
||||
|
||||
export type MarketplaceCatalogPage = {
|
||||
items: MarketplaceCatalogItem[];
|
||||
nextCursor: string | null;
|
||||
total: number;
|
||||
catalogGeneration: number;
|
||||
etag: string;
|
||||
pricingVersionId: string | null;
|
||||
stale: boolean;
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
export type MarketplaceOperation = {
|
||||
capabilityId: string;
|
||||
operation: string;
|
||||
executionMode: 'synchronous' | 'job';
|
||||
billing: {
|
||||
mode: 'included' | 'platform_metered';
|
||||
status: 'billing_unavailable' | null;
|
||||
notice: string;
|
||||
entitlementScope: string | null;
|
||||
unitName: string | null;
|
||||
unitSize: number | null;
|
||||
ratePoints: string | null;
|
||||
minimumChargePoints: string | null;
|
||||
roundingMode: 'ceil' | null;
|
||||
pricingVersion: string | null;
|
||||
};
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type MarketplacePluginDetail = MarketplaceCatalogItem & {
|
||||
descriptionMarkdown: string;
|
||||
permissions: string[];
|
||||
operations: MarketplaceOperation[];
|
||||
stableRelease: { releaseId: string; version: string } | null;
|
||||
betaRelease: { releaseId: string; version: string } | null;
|
||||
etag: string;
|
||||
pricingVersionId: string | null;
|
||||
stale: boolean;
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
export type MarketplaceLibraryEntry = {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
acquisition: MarketplaceAcquisition;
|
||||
acquisitionMode: 'system_included' | 'user_acquired';
|
||||
catalogStatus: 'active' | 'retired';
|
||||
runtimeStatus: 'enabled' | 'suspended';
|
||||
acquiredAt: string | null;
|
||||
removedAt: string | null;
|
||||
stableVersion: string | null;
|
||||
betaVersion: string | null;
|
||||
};
|
||||
|
||||
export type MarketplaceLibrarySnapshot = {
|
||||
items: MarketplaceLibraryEntry[];
|
||||
total: number;
|
||||
stale: boolean;
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
export type MarketplaceInstallation = {
|
||||
status: 'installed' | 'kept' | 'removed' | 'unavailable';
|
||||
pluginId: string;
|
||||
releaseId?: string;
|
||||
version?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceLibraryProjection = {
|
||||
library: MarketplaceLibrarySnapshot;
|
||||
installations: MarketplaceInstallation[];
|
||||
};
|
||||
|
||||
export type MarketplaceCatalogQuery = {
|
||||
query?: string;
|
||||
category?: string;
|
||||
featured?: boolean;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceFetcher = <T>(path: string, init?: RequestInit) => Promise<T>;
|
||||
|
||||
const PLUGIN_ID = /^[a-z][a-z0-9.-]{0,127}$/u;
|
||||
const defaultFetch: MarketplaceFetcher = hostApiFetch;
|
||||
|
||||
function object(value: unknown, name: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, name: string, maximum = 32_000): string {
|
||||
if (typeof value !== 'string' || !value || value.length > maximum) throw new Error(`${name} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, name: string): string | null {
|
||||
return value === null ? null : text(value, name, 512);
|
||||
}
|
||||
|
||||
function integer(value: unknown, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} is invalid`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, name: string): boolean {
|
||||
if (typeof value !== 'boolean') throw new Error(`${name} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, values: readonly T[], name: string): T {
|
||||
if (typeof value !== 'string' || !values.includes(value as T)) throw new Error(`${name} is invalid`);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function pluginId(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!PLUGIN_ID.test(normalized)) throw new Error('plugin id is invalid');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function catalogItem(value: unknown): MarketplaceCatalogItem {
|
||||
const item = object(value, 'catalog item');
|
||||
if (!Array.isArray(item.tags)) throw new Error('catalog item tags are invalid');
|
||||
return {
|
||||
pluginId: pluginId(text(item.pluginId, 'pluginId', 128)),
|
||||
title: text(item.title, 'title', 256),
|
||||
summary: text(item.summary, 'summary', 2_000),
|
||||
category: text(item.category, 'category', 128),
|
||||
tags: item.tags.map((tag) => text(tag, 'tag', 128)),
|
||||
providerDisplayName: text(item.providerDisplayName, 'providerDisplayName', 256),
|
||||
runtimeKind: oneOf(item.runtimeKind, ['skill_only', 'bundled_typed', 'platform_hosted'], 'runtimeKind'),
|
||||
runtimeStatus: oneOf(item.runtimeStatus, ['enabled', 'suspended'], 'runtimeStatus'),
|
||||
acquisition: oneOf(item.acquisition, ['free', 'system_included'], 'acquisition'),
|
||||
usageBilling: oneOf(item.usageBilling, ['included', 'token_point', 'mixed'], 'usageBilling'),
|
||||
includedOperationCount: integer(item.includedOperationCount, 'includedOperationCount'),
|
||||
meteredOperationCount: integer(item.meteredOperationCount, 'meteredOperationCount'),
|
||||
stableVersion: nullableText(item.stableVersion, 'stableVersion'),
|
||||
betaVersion: nullableText(item.betaVersion, 'betaVersion'),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMarketplaceCatalog(value: unknown): MarketplaceCatalogPage {
|
||||
const page = object(value, 'catalog');
|
||||
if (!Array.isArray(page.items)) throw new Error('catalog items are invalid');
|
||||
return {
|
||||
items: page.items.map(catalogItem), nextCursor: nullableText(page.nextCursor, 'nextCursor'),
|
||||
total: integer(page.total, 'total'), catalogGeneration: integer(page.catalogGeneration, 'catalogGeneration'),
|
||||
etag: text(page.etag, 'etag', 512), pricingVersionId: nullableText(page.pricingVersionId, 'pricingVersionId'),
|
||||
stale: boolean(page.stale, 'stale'), fetchedAt: integer(page.fetchedAt, 'fetchedAt'),
|
||||
};
|
||||
}
|
||||
|
||||
function libraryEntry(value: unknown): MarketplaceLibraryEntry {
|
||||
const item = object(value, 'library item');
|
||||
return {
|
||||
pluginId: pluginId(text(item.pluginId, 'pluginId', 128)), title: text(item.title, 'title', 256),
|
||||
summary: text(item.summary, 'summary', 2_000), category: text(item.category, 'category', 128),
|
||||
acquisition: oneOf(item.acquisition, ['free', 'system_included'], 'acquisition'),
|
||||
acquisitionMode: oneOf(item.acquisitionMode, ['system_included', 'user_acquired'], 'acquisitionMode'),
|
||||
catalogStatus: oneOf(item.catalogStatus, ['active', 'retired'], 'catalogStatus'),
|
||||
runtimeStatus: oneOf(item.runtimeStatus, ['enabled', 'suspended'], 'runtimeStatus'),
|
||||
acquiredAt: nullableText(item.acquiredAt, 'acquiredAt'), removedAt: nullableText(item.removedAt, 'removedAt'),
|
||||
stableVersion: nullableText(item.stableVersion, 'stableVersion'), betaVersion: nullableText(item.betaVersion, 'betaVersion'),
|
||||
};
|
||||
}
|
||||
|
||||
function installation(value: unknown): MarketplaceInstallation {
|
||||
const item = object(value, 'installation');
|
||||
return {
|
||||
status: oneOf(item.status, ['installed', 'kept', 'removed', 'unavailable'], 'installation.status'),
|
||||
pluginId: pluginId(text(item.pluginId, 'installation.pluginId', 128)),
|
||||
...(item.releaseId === undefined ? {} : { releaseId: text(item.releaseId, 'installation.releaseId', 128) }),
|
||||
...(item.version === undefined ? {} : { version: text(item.version, 'installation.version', 64) }),
|
||||
...(item.reason === undefined ? {} : { reason: text(item.reason, 'installation.reason', 256) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMarketplaceLibrary(value: unknown): MarketplaceLibraryProjection {
|
||||
const projection = object(value, 'library projection');
|
||||
const snapshot = object(projection.library, 'library');
|
||||
if (!Array.isArray(snapshot.items) || !Array.isArray(projection.installations)) throw new Error('library projection arrays are invalid');
|
||||
return {
|
||||
library: {
|
||||
items: snapshot.items.map(libraryEntry), total: integer(snapshot.total, 'library.total'),
|
||||
stale: boolean(snapshot.stale, 'library.stale'), fetchedAt: integer(snapshot.fetchedAt, 'library.fetchedAt'),
|
||||
},
|
||||
installations: projection.installations.map(installation),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMarketplaceInstallation(value: unknown): MarketplaceInstallation {
|
||||
return installation(value);
|
||||
}
|
||||
|
||||
export function parseMarketplaceDetail(value: unknown): MarketplacePluginDetail {
|
||||
const source = object(value, 'plugin detail');
|
||||
const base = catalogItem(source);
|
||||
if (!Array.isArray(source.permissions) || !Array.isArray(source.operations)) throw new Error('plugin detail arrays are invalid');
|
||||
const release = (value: unknown) => value === null ? null : (() => {
|
||||
const item = object(value, 'release');
|
||||
return { releaseId: text(item.releaseId, 'releaseId', 128), version: text(item.version, 'version', 64) };
|
||||
})();
|
||||
return {
|
||||
...base,
|
||||
descriptionMarkdown: text(source.descriptionMarkdown, 'descriptionMarkdown'),
|
||||
permissions: source.permissions.map((permission) => text(permission, 'permission', 128)),
|
||||
operations: source.operations as MarketplaceOperation[],
|
||||
stableRelease: release(source.stableRelease), betaRelease: release(source.betaRelease),
|
||||
etag: text(source.etag, 'etag', 512), pricingVersionId: nullableText(source.pricingVersionId, 'pricingVersionId'),
|
||||
stale: boolean(source.stale, 'stale'), fetchedAt: integer(source.fetchedAt, 'fetchedAt'),
|
||||
};
|
||||
}
|
||||
|
||||
function queryString(input: MarketplaceCatalogQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
if (input.query?.trim()) params.set('query', input.query.trim());
|
||||
if (input.category?.trim()) params.set('category', input.category.trim());
|
||||
if (input.featured !== undefined) params.set('featured', String(input.featured));
|
||||
if (input.limit !== undefined) params.set('limit', String(input.limit));
|
||||
if (input.cursor) params.set('cursor', input.cursor);
|
||||
const value = params.toString();
|
||||
return value ? `?${value}` : '';
|
||||
}
|
||||
|
||||
export async function readMarketplaceCatalog(input: MarketplaceCatalogQuery = {}, fetcher = defaultFetch) {
|
||||
return parseMarketplaceCatalog(await fetcher(`/api/coding/plugin-marketplace/catalog${queryString(input)}`));
|
||||
}
|
||||
|
||||
export async function readMarketplaceDetail(id: string, fetcher = defaultFetch) {
|
||||
return parseMarketplaceDetail(await fetcher(`/api/coding/plugin-marketplace/plugins/${encodeURIComponent(pluginId(id))}`));
|
||||
}
|
||||
|
||||
export async function readMarketplaceLibrary(fetcher = defaultFetch) {
|
||||
return parseMarketplaceLibrary(await fetcher('/api/coding/plugin-marketplace/library'));
|
||||
}
|
||||
|
||||
async function libraryMutation(method: 'PUT' | 'DELETE', id: string, fetcher: MarketplaceFetcher) {
|
||||
return parseMarketplaceLibrary(await fetcher(`/api/coding/plugin-marketplace/library/${encodeURIComponent(pluginId(id))}`, { method, body: '{}' }));
|
||||
}
|
||||
|
||||
export async function acquireMarketplacePlugin(id: string, fetcher = defaultFetch) {
|
||||
return libraryMutation('PUT', id, fetcher);
|
||||
}
|
||||
|
||||
export async function removeMarketplacePlugin(id: string, fetcher = defaultFetch) {
|
||||
return libraryMutation('DELETE', id, fetcher);
|
||||
}
|
||||
|
||||
async function installationMutation(method: 'POST' | 'DELETE', segment: 'install' | 'update', id: string, fetcher: MarketplaceFetcher) {
|
||||
return parseMarketplaceInstallation(await fetcher(`/api/coding/plugin-marketplace/${segment}/${encodeURIComponent(pluginId(id))}`, { method, body: '{}' }));
|
||||
}
|
||||
|
||||
export async function installMarketplacePlugin(id: string, fetcher = defaultFetch) {
|
||||
return installationMutation('POST', 'install', id, fetcher);
|
||||
}
|
||||
|
||||
export async function updateMarketplacePlugin(id: string, fetcher = defaultFetch) {
|
||||
return installationMutation('POST', 'update', id, fetcher);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplacePlugin(id: string, fetcher = defaultFetch) {
|
||||
return installationMutation('DELETE', 'install', id, fetcher);
|
||||
}
|
||||
98
src/pages/MyPlugins/index.tsx
Normal file
98
src/pages/MyPlugins/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertCircle, Download, PackageCheck, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
|
||||
import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
function failureText(reason?: string): string | null {
|
||||
if (!reason) return null;
|
||||
const messages: string[] = [];
|
||||
if (reason.includes('signature')) messages.push('签名校验失败,旧版本仍保留。');
|
||||
if (reason.includes('yanked') || reason.includes('not_ready')) messages.push('版本已撤回或未就绪。');
|
||||
if (reason.includes('incompatible')) messages.push('当前 MakeLore 版本不兼容。');
|
||||
if (messages.length === 0) messages.push('当前版本暂不可用,旧版本仍保留。');
|
||||
return messages.join(' ');
|
||||
}
|
||||
|
||||
function actionErrorText(error?: string | null): string | null {
|
||||
if (!error) return null;
|
||||
if (error.includes('plugin_artifact_invalid')) return '插件签名或包校验失败,设备上此前可用版本仍保留。';
|
||||
if (error.includes('plugin_release_not_ready')) return '版本已撤回、尚未就绪或与当前 MakeLore 不兼容;不会替换此前可用版本。';
|
||||
return error;
|
||||
}
|
||||
|
||||
export type MyPluginsViewProps = {
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
installations: Record<string, MarketplaceInstallation>;
|
||||
state: 'idle' | 'loading' | 'ready' | 'error';
|
||||
error?: string | null;
|
||||
pending: Record<string, true>;
|
||||
onRefresh(): void | Promise<void>;
|
||||
onInstall(pluginId: string): void | Promise<void>;
|
||||
onUpdate(pluginId: string): void | Promise<void>;
|
||||
onUninstall(pluginId: string): void | Promise<void>;
|
||||
onRemove(pluginId: string): void | Promise<void>;
|
||||
onReacquire(pluginId: string): void | Promise<void>;
|
||||
};
|
||||
|
||||
export function MyPluginsView(props: MyPluginsViewProps) {
|
||||
const visibleError = actionErrorText(props.error);
|
||||
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>
|
||||
{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}
|
||||
<section aria-label="账号插件库" className="mt-6 space-y-4">
|
||||
{props.library?.items.map((plugin) => {
|
||||
const installed = props.installations[plugin.pluginId];
|
||||
const removed = plugin.removedAt !== null;
|
||||
const suspended = plugin.runtimeStatus === 'suspended';
|
||||
const retired = plugin.catalogStatus === 'retired';
|
||||
const updateAvailable = Boolean(installed?.version && plugin.stableVersion && installed.version !== plugin.stableVersion);
|
||||
const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
|
||||
const failure = failureText(installed?.reason);
|
||||
return <article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{plugin.acquisition === 'system_included' ? <Badge variant="outline">系统内置</Badge> : removed ? <Badge variant="outline">已移除</Badge> : <Badge variant="secondary">已获取</Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums">稳定版 {plugin.stableVersion ?? '不可用'}</p>{installed?.version ? <p className="mt-1 tabular-nums text-muted-foreground">设备版本 {installed.version}</p> : <p className="mt-1 text-muted-foreground">尚未下载到设备</p>}</div></div>
|
||||
<div className="mt-4 space-y-1 text-pretty text-sm">{suspended ? <p className="text-destructive">运行已暂停</p> : null}{retired ? <p>{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}</p> : null}{failure ? <p className="text-destructive"><AlertCircle className="mr-1 inline h-4 w-4" />{failure}</p> : null}</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{removed ? <Button type="button" className="min-h-10" disabled={busy || retired || suspended} aria-label={`重新免费获取${plugin.title}`} onClick={() => void props.onReacquire(plugin.pluginId)}>重新免费获取</Button>
|
||||
: !installed ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`下载${plugin.title}`} onClick={() => void props.onInstall(plugin.pluginId)}><Download className="mr-2 h-4 w-4" />下载</Button>
|
||||
: updateAvailable ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`更新${plugin.title}`} onClick={() => void props.onUpdate(plugin.pluginId)}><RefreshCw className="mr-2 h-4 w-4" />更新</Button>
|
||||
: <span className="inline-flex min-h-10 items-center px-2 text-sm font-medium"><PackageCheck className="mr-2 h-4 w-4 text-brand" />设备版本已是最新</span>}
|
||||
{!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}
|
||||
{installed ? <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">分配给伙伴</Link></Button>
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyPlugins() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const accountKey = user?.userId ?? user?.username ?? null;
|
||||
const library = usePluginMarketplaceStore((state) => state.library);
|
||||
const installations = usePluginMarketplaceStore((state) => state.installations);
|
||||
const state = usePluginMarketplaceStore((value) => value.libraryState);
|
||||
const error = usePluginMarketplaceStore((value) => value.libraryError);
|
||||
const pending = usePluginMarketplaceStore((value) => value.pending);
|
||||
useEffect(() => {
|
||||
pluginMarketplaceStore.getState().activateAccount(accountKey);
|
||||
if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
|
||||
}, [accountKey]);
|
||||
const safe = (operation: Promise<void>) => operation.catch((reason) => {
|
||||
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))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />;
|
||||
}
|
||||
129
src/pages/PluginMarketplace/index.tsx
Normal file
129
src/pages/PluginMarketplace/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
MarketplaceCatalogPage,
|
||||
MarketplaceCatalogQuery,
|
||||
MarketplaceLibrarySnapshot,
|
||||
MarketplacePluginDetail,
|
||||
MarketplaceUsageBilling,
|
||||
} from '@/lib/plugin-marketplace';
|
||||
import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const USAGE_COPY: Record<MarketplaceUsageBilling, string> = {
|
||||
included: '当前包含,不按单次调用扣点',
|
||||
token_point: '按 Token Point 实际用量计费',
|
||||
mixed: '部分能力包含,部分按 Token Point 用量计费',
|
||||
};
|
||||
|
||||
export type PluginMarketplaceViewProps = {
|
||||
catalog: MarketplaceCatalogPage | null;
|
||||
detail: MarketplacePluginDetail | null;
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
catalogState: 'idle' | 'loading' | 'ready' | 'error';
|
||||
catalogError?: string | null;
|
||||
pending: Record<string, true>;
|
||||
onSearch(input: MarketplaceCatalogQuery): void | Promise<void>;
|
||||
onSelect(pluginId: string): void | Promise<void>;
|
||||
onAcquire(pluginId: string): void | Promise<void>;
|
||||
};
|
||||
|
||||
export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const categories = useMemo(() => [...new Set(props.catalog?.items.map((item) => item.category) ?? [])].sort(), [props.catalog]);
|
||||
const acquiredIds = useMemo(() => new Set(props.library?.items
|
||||
.filter(({ removedAt }) => removedAt === null)
|
||||
.map(({ pluginId }) => pluginId) ?? []), [props.library]);
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
void props.onSearch({ ...(query.trim() ? { query: query.trim() } : {}), ...(category ? { category } : {}), limit: 24 });
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-testid="plugin-marketplace-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">浏览 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}
|
||||
</header>
|
||||
|
||||
<form role="search" className="mt-6 flex flex-wrap gap-3" onSubmit={submit}>
|
||||
<label className="relative min-w-[220px] flex-1">
|
||||
<span className="sr-only">搜索插件</span>
|
||||
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<input type="search" aria-label="搜索插件" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称、简介或标签" className="min-h-10 w-full rounded-xl border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-brand/50 focus:ring-2 focus:ring-brand/20" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="sr-only">插件分类</span>
|
||||
<select aria-label="插件分类" value={category} onChange={(event) => setCategory(event.target.value)} className="min-h-10 rounded-xl border border-border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-brand/25">
|
||||
<option value="">全部分类</option>
|
||||
{categories.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" className="min-h-10"><Search className="mr-2 h-4 w-4" />搜索</Button>
|
||||
</form>
|
||||
|
||||
{props.catalog?.stale ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">正在显示上次可信的缓存目录;刷新成功前不会把本地安装包伪装成新的目录信息。</p> : null}
|
||||
{props.catalogState === 'loading' && !props.catalog ? <p role="status" className="mt-8 text-sm text-muted-foreground">正在读取插件目录…</p> : null}
|
||||
{props.catalogState === 'error' && !props.catalog ? <div role="alert" className="mt-8 rounded-2xl bg-warning/10 p-5"><h2 className="text-balance font-semibold">插件目录暂不可用</h2><p className="mt-2 text-pretty text-sm text-muted-foreground">首次读取失败,不会从本地包推测目录或计费策略。{props.catalogError ? ` ${props.catalogError}` : ''}</p></div> : null}
|
||||
{props.catalog && props.catalog.items.length === 0 ? <div className="mt-8 rounded-2xl bg-surface-subtle p-6 text-center"><Puzzle 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></div> : null}
|
||||
|
||||
<section aria-label="插件目录" className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{props.catalog?.items.map((plugin) => {
|
||||
const acquired = acquiredIds.has(plugin.pluginId);
|
||||
const acquiring = Boolean(props.pending[`acquire:${plugin.pluginId}`]);
|
||||
return (
|
||||
<article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70 transition-[box-shadow] duration-150 hover:shadow-float">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><Badge variant="secondary">{plugin.category}</Badge><h2 className="mt-3 text-balance text-xl font-semibold">{plugin.title}</h2></div>
|
||||
{plugin.acquisition === 'system_included' ? <Badge variant="outline">系统内置</Badge> : <Badge variant="outline">免费获取</Badge>}
|
||||
</div>
|
||||
<p className="mt-3 text-pretty text-sm text-muted-foreground">{plugin.summary}</p>
|
||||
<dl className="mt-4 grid gap-2 text-sm">
|
||||
<div className="flex justify-between gap-3"><dt className="text-muted-foreground">提供方</dt><dd>{plugin.providerDisplayName}</dd></div>
|
||||
<div className="flex justify-between gap-3"><dt className="text-muted-foreground">稳定版</dt><dd className="tabular-nums">{plugin.stableVersion ?? '暂不可用'}</dd></div>
|
||||
</dl>
|
||||
<p className="mt-4 text-pretty text-sm font-medium">{USAGE_COPY[plugin.usageBilling]}</p>
|
||||
{plugin.runtimeStatus === 'suspended' ? <p className="mt-3 text-pretty text-sm text-destructive">运行已暂停,暂不能获取或下载。</p> : null}
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onSelect(plugin.pluginId)}>查看详情</Button>
|
||||
{plugin.acquisition === 'system_included' || acquired
|
||||
? <span className="inline-flex min-h-10 items-center gap-2 px-2 text-sm font-medium"><CheckCircle2 className="h-4 w-4 text-brand" />{plugin.acquisition === 'system_included' ? '随 MakeLore 提供' : '已免费获取'}</span>
|
||||
: <Button type="button" className="min-h-10" disabled={acquiring || plugin.runtimeStatus === 'suspended'} aria-label={`免费获取${plugin.title}`} onClick={() => void props.onAcquire(plugin.pluginId)}><Download className="mr-2 h-4 w-4" />免费获取</Button>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginMarketplace() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const accountKey = user?.userId ?? user?.username ?? null;
|
||||
const catalog = usePluginMarketplaceStore((state) => state.catalog);
|
||||
const catalogState = usePluginMarketplaceStore((state) => state.catalogState);
|
||||
const catalogError = usePluginMarketplaceStore((state) => state.catalogError);
|
||||
const library = usePluginMarketplaceStore((state) => state.library);
|
||||
const details = usePluginMarketplaceStore((state) => state.details);
|
||||
const pending = usePluginMarketplaceStore((state) => state.pending);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
pluginMarketplaceStore.getState().activateAccount(accountKey);
|
||||
void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined);
|
||||
if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
|
||||
}, [accountKey]);
|
||||
const safe = (operation: Promise<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))} />;
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import type { CodingPluginItem, CodingPluginProject, PluginBilling } from '@/lib/coding-plugins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
|
||||
import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import type { DataServiceInstanceState } from '../../../shared/data-service';
|
||||
|
||||
@@ -45,6 +48,8 @@ type ViewProps = {
|
||||
pending: Record<string, true>;
|
||||
agentNames: Record<string, string>;
|
||||
refreshError?: string | null;
|
||||
marketplaceLibrary?: MarketplaceLibrarySnapshot | null;
|
||||
installations?: Record<string, MarketplaceInstallation>;
|
||||
onRefresh(): void | Promise<void>;
|
||||
onSetEnabled(pluginId: string, enabled: boolean): void | Promise<void>;
|
||||
onConfigure(collections: string[]): void | Promise<void>;
|
||||
@@ -62,6 +67,8 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
? PLUGIN_SETTINGS_SURFACES[selected.settingsSurface as keyof typeof PLUGIN_SETTINGS_SURFACES]
|
||||
: null;
|
||||
const pendingToggle = selected ? Boolean(props.pending[`enabled:${props.projection.project.localProjectId}:${selected.id}`]) : false;
|
||||
const libraryEntry = selected ? props.marketplaceLibrary?.items.find(({ pluginId }) => pluginId === selected.id) : null;
|
||||
const installation = selected ? props.installations?.[selected.id] : null;
|
||||
|
||||
return (
|
||||
<main data-testid="project-plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
|
||||
@@ -83,8 +90,10 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
{selected ? (
|
||||
<article className="rounded-3xl bg-background p-5 shadow-soft ring-1 ring-border/70 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-2xl font-semibold">{selected.displayName}</h2><Badge variant="outline"><StateMark state={selected.state} /></Badge></div><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground">{selected.description}</p></div>
|
||||
{selected.enabled ? <Button type="button" variant="outline" disabled={pendingToggle} onClick={() => setDisableCandidate(selected)} aria-label={`禁用${selected.displayName}`}>禁用</Button> : <Button type="button" disabled={pendingToggle || selected.state === 'unavailable'} onClick={() => void props.onSetEnabled(selected.id, true)} aria-label={`启用${selected.displayName}`}>启用</Button>}
|
||||
{selected.enabled ? <Button type="button" variant="outline" className="min-h-10" disabled={pendingToggle} onClick={() => setDisableCandidate(selected)} aria-label={`禁用${selected.displayName}`}>禁用</Button> : <Button type="button" className="min-h-10" disabled={pendingToggle || selected.state === 'unavailable'} onClick={() => void props.onSetEnabled(selected.id, true)} aria-label={`启用到项目${selected.displayName}`}>启用到项目</Button>}
|
||||
</div>
|
||||
{libraryEntry?.removedAt ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">账号插件库已移除;项目配置仍保留此插件 ID,重新免费获取后可恢复。</p> : null}
|
||||
{libraryEntry?.acquisitionMode === 'user_acquired' && !libraryEntry.removedAt && !installation ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">插件已免费获取但尚未下载到本机,当前项目配置会继续保留。</p> : null}
|
||||
<section className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4" aria-label="插件概览">
|
||||
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">发布者</p><p className="mt-1 font-semibold">MakeLore</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.version}</p></div>
|
||||
@@ -96,7 +105,7 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
<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" 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={() => navigate('/project-config')}>分配给伙伴</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>
|
||||
@@ -104,12 +113,15 @@ export function ProjectPluginsView(props: ViewProps) {
|
||||
</article>
|
||||
) : <p className="text-sm text-muted-foreground">当前没有可用插件。</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectPlugins() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const accountKey = user?.userId ?? user?.username ?? null;
|
||||
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
||||
const config = useCodingWorkspaceStore((state) => state.config);
|
||||
const projection = useCodingPluginsStore((state) => state.projection);
|
||||
@@ -117,8 +129,14 @@ export function ProjectPlugins() {
|
||||
const pending = useCodingPluginsStore((state) => state.pending);
|
||||
const loadState = useCodingPluginsStore((state) => state.loadState);
|
||||
const error = useCodingPluginsStore((state) => state.error);
|
||||
const marketplaceLibrary = usePluginMarketplaceStore((state) => state.library);
|
||||
const installations = usePluginMarketplaceStore((state) => state.installations);
|
||||
const agentNames = useMemo(() => Object.fromEntries((config?.agents ?? []).map((agent) => [agent.id, agent.name || agent.roleName])), [config]);
|
||||
useEffect(() => { if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined); }, [activeProject]);
|
||||
useEffect(() => {
|
||||
pluginMarketplaceStore.getState().activateAccount(accountKey);
|
||||
if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
|
||||
}, [accountKey]);
|
||||
if (!activeProject) return <p className="p-6 text-pretty text-sm text-muted-foreground">请先选择一个项目。</p>;
|
||||
const currentProjection = projection?.project.localProjectId === activeProject.id ? projection : null;
|
||||
if (!currentProjection && loadState === 'loading') return <p role="status" className="p-6 text-sm">正在读取项目插件…</p>;
|
||||
@@ -126,5 +144,5 @@ 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} 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())} />;
|
||||
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())} />;
|
||||
}
|
||||
|
||||
253
src/stores/plugin-marketplace.ts
Normal file
253
src/stores/plugin-marketplace.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { createStore, type StoreApi } from 'zustand';
|
||||
import { useStore } from 'zustand';
|
||||
import {
|
||||
acquireMarketplacePlugin,
|
||||
installMarketplacePlugin,
|
||||
readMarketplaceCatalog,
|
||||
readMarketplaceDetail,
|
||||
readMarketplaceLibrary,
|
||||
removeMarketplacePlugin,
|
||||
uninstallMarketplacePlugin,
|
||||
updateMarketplacePlugin,
|
||||
type MarketplaceCatalogPage,
|
||||
type MarketplaceCatalogQuery,
|
||||
type MarketplaceInstallation,
|
||||
type MarketplaceLibraryProjection,
|
||||
type MarketplaceLibrarySnapshot,
|
||||
type MarketplacePluginDetail,
|
||||
} from '@/lib/plugin-marketplace';
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export type PluginMarketplaceDependencies = {
|
||||
readCatalog(input?: MarketplaceCatalogQuery): Promise<MarketplaceCatalogPage>;
|
||||
readDetail(pluginId: string): Promise<MarketplacePluginDetail>;
|
||||
readLibrary(): Promise<MarketplaceLibraryProjection>;
|
||||
acquire(pluginId: string): Promise<MarketplaceLibraryProjection>;
|
||||
remove(pluginId: string): Promise<MarketplaceLibraryProjection>;
|
||||
install(pluginId: string): Promise<MarketplaceInstallation>;
|
||||
update(pluginId: string): Promise<MarketplaceInstallation>;
|
||||
uninstall(pluginId: string): Promise<MarketplaceInstallation>;
|
||||
};
|
||||
|
||||
export type PluginMarketplaceState = {
|
||||
accountKey: string | null;
|
||||
catalog: MarketplaceCatalogPage | null;
|
||||
catalogState: LoadState;
|
||||
catalogError: string | null;
|
||||
catalogQuery: MarketplaceCatalogQuery;
|
||||
library: MarketplaceLibrarySnapshot | null;
|
||||
libraryState: LoadState;
|
||||
libraryError: string | null;
|
||||
installations: Record<string, MarketplaceInstallation>;
|
||||
details: Record<string, MarketplacePluginDetail>;
|
||||
detailState: Record<string, LoadState>;
|
||||
pending: Record<string, true>;
|
||||
activateAccount(accountKey: string | null): void;
|
||||
loadCatalog(input?: MarketplaceCatalogQuery): Promise<void>;
|
||||
loadDetail(pluginId: string): Promise<void>;
|
||||
loadLibrary(): Promise<void>;
|
||||
acquire(pluginId: string): Promise<void>;
|
||||
remove(pluginId: string): Promise<void>;
|
||||
install(pluginId: string): Promise<void>;
|
||||
update(pluginId: string): Promise<void>;
|
||||
uninstall(pluginId: string): Promise<void>;
|
||||
};
|
||||
|
||||
const defaults: PluginMarketplaceDependencies = {
|
||||
readCatalog: readMarketplaceCatalog,
|
||||
readDetail: readMarketplaceDetail,
|
||||
readLibrary: readMarketplaceLibrary,
|
||||
acquire: acquireMarketplacePlugin,
|
||||
remove: removeMarketplacePlugin,
|
||||
install: installMarketplacePlugin,
|
||||
update: updateMarketplacePlugin,
|
||||
uninstall: uninstallMarketplacePlugin,
|
||||
};
|
||||
|
||||
function message(error: unknown): string {
|
||||
const base = error instanceof Error ? error.message : String(error);
|
||||
if (!error || typeof error !== 'object') return base;
|
||||
const details = 'details' in error && error.details && typeof error.details === 'object'
|
||||
? error.details as Record<string, unknown>
|
||||
: null;
|
||||
const backendCode = details?.backendCode;
|
||||
return typeof backendCode === 'string' ? `${backendCode}: ${base}` : base;
|
||||
}
|
||||
|
||||
function installationMap(items: readonly MarketplaceInstallation[]): Record<string, MarketplaceInstallation> {
|
||||
return Object.fromEntries(items.map((item) => [item.pluginId, item]));
|
||||
}
|
||||
|
||||
export function createPluginMarketplaceStore(
|
||||
overrides: Partial<PluginMarketplaceDependencies> = {},
|
||||
): StoreApi<PluginMarketplaceState> {
|
||||
const deps = { ...defaults, ...overrides };
|
||||
let scopeGeneration = 0;
|
||||
let catalogGeneration = 0;
|
||||
let libraryMutationEpoch = 0;
|
||||
|
||||
return createStore<PluginMarketplaceState>((set, get) => {
|
||||
const pending = async <T>(key: string, operation: () => Promise<T>): Promise<T> => {
|
||||
set((state) => ({ pending: { ...state.pending, [key]: true } }));
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
set((state) => {
|
||||
const next = { ...state.pending };
|
||||
delete next[key];
|
||||
return { pending: next };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const libraryAction = async (
|
||||
key: string,
|
||||
pluginId: string,
|
||||
action: (pluginId: string) => Promise<MarketplaceLibraryProjection>,
|
||||
): Promise<void> => pending(key, async () => {
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
if (!accountKey) throw new Error('请先登录后管理我的插件');
|
||||
try {
|
||||
const projection = await action(pluginId);
|
||||
if (generation !== scopeGeneration || get().accountKey !== accountKey) return;
|
||||
libraryMutationEpoch += 1;
|
||||
set({
|
||||
library: projection.library,
|
||||
installations: installationMap(projection.installations),
|
||||
libraryState: 'ready',
|
||||
libraryError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey) {
|
||||
set({ libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const installAction = async (
|
||||
key: string,
|
||||
pluginId: string,
|
||||
action: (pluginId: string) => Promise<MarketplaceInstallation>,
|
||||
): Promise<void> => pending(key, async () => {
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
if (!accountKey) throw new Error('请先登录后管理设备插件');
|
||||
try {
|
||||
const result = await action(pluginId);
|
||||
if (generation !== scopeGeneration || get().accountKey !== accountKey) return;
|
||||
libraryMutationEpoch += 1;
|
||||
set((state) => {
|
||||
const installations = { ...state.installations };
|
||||
if (result.status === 'removed') delete installations[pluginId];
|
||||
else installations[pluginId] = result;
|
||||
return { installations, libraryError: null };
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey) {
|
||||
set({ libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accountKey: null,
|
||||
catalog: null,
|
||||
catalogState: 'idle',
|
||||
catalogError: null,
|
||||
catalogQuery: {},
|
||||
library: null,
|
||||
libraryState: 'idle',
|
||||
libraryError: null,
|
||||
installations: {},
|
||||
details: {},
|
||||
detailState: {},
|
||||
pending: {},
|
||||
activateAccount(accountKey) {
|
||||
if (get().accountKey === accountKey) return;
|
||||
scopeGeneration += 1;
|
||||
libraryMutationEpoch = 0;
|
||||
set({
|
||||
accountKey,
|
||||
library: null,
|
||||
libraryState: 'idle',
|
||||
libraryError: null,
|
||||
installations: {},
|
||||
pending: {},
|
||||
});
|
||||
},
|
||||
async loadCatalog(input = {}) {
|
||||
const generation = ++catalogGeneration;
|
||||
set({ catalogState: 'loading', catalogError: null, catalogQuery: input });
|
||||
try {
|
||||
const catalog = await deps.readCatalog(input);
|
||||
if (generation === catalogGeneration) set({ catalog, catalogState: 'ready', catalogError: null });
|
||||
} catch (error) {
|
||||
if (generation === catalogGeneration) set({ catalogState: 'error', catalogError: message(error) });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async loadDetail(pluginId) {
|
||||
const generation = scopeGeneration;
|
||||
set((state) => ({ detailState: { ...state.detailState, [pluginId]: 'loading' } }));
|
||||
try {
|
||||
const detail = await deps.readDetail(pluginId);
|
||||
if (generation !== scopeGeneration) return;
|
||||
set((state) => ({
|
||||
details: { ...state.details, [pluginId]: detail },
|
||||
detailState: { ...state.detailState, [pluginId]: 'ready' },
|
||||
}));
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration) {
|
||||
set((state) => ({
|
||||
detailState: { ...state.detailState, [pluginId]: 'error' },
|
||||
catalogError: message(error),
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async loadLibrary() {
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
const mutationEpoch = libraryMutationEpoch;
|
||||
if (!accountKey) {
|
||||
set({ library: null, installations: {}, libraryState: 'idle', libraryError: null });
|
||||
return;
|
||||
}
|
||||
set({ libraryState: 'loading', libraryError: null });
|
||||
try {
|
||||
const projection = await deps.readLibrary();
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey
|
||||
&& mutationEpoch === libraryMutationEpoch) {
|
||||
set({
|
||||
library: projection.library,
|
||||
installations: installationMap(projection.installations),
|
||||
libraryState: 'ready',
|
||||
libraryError: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey) {
|
||||
set({ libraryState: 'error', libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
acquire: (pluginId) => libraryAction(`acquire:${pluginId}`, pluginId, deps.acquire),
|
||||
remove: (pluginId) => libraryAction(`remove:${pluginId}`, pluginId, deps.remove),
|
||||
install: (pluginId) => installAction(`install:${pluginId}`, pluginId, deps.install),
|
||||
update: (pluginId) => installAction(`update:${pluginId}`, pluginId, deps.update),
|
||||
uninstall: (pluginId) => installAction(`uninstall:${pluginId}`, pluginId, deps.uninstall),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const pluginMarketplaceStore = createPluginMarketplaceStore();
|
||||
|
||||
export function usePluginMarketplaceStore<T>(selector: (state: PluginMarketplaceState) => T): T {
|
||||
return useStore(pluginMarketplaceStore, selector);
|
||||
}
|
||||
Reference in New Issue
Block a user