feat: add project plugin center

This commit is contained in:
2026-08-27 18:36:45 +08:00
parent b6d9e6156f
commit cb1fd2629c
15 changed files with 982 additions and 1 deletions

View File

@@ -32,6 +32,7 @@ const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({
const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component })));
const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component })));
const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component })));
const ProjectPlugins = lazy(() => import('./pages/ProjectPlugins').then(({ ProjectPlugins: component }) => ({ default: component })));
const 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 })));
@@ -453,6 +454,7 @@ function App() {
)}
>
<Route path="/project-config" element={<ProjectConfiguration />} />
<Route path="/project-plugins" element={<ProjectPlugins />} />
<Route path="/makelore-home" element={<Home />} />
<Route path="/kangaroo" element={<Navigate to="/project-config" replace />} />
<Route path="/subagents" element={<Navigate to="/project-config" replace />} />

View File

@@ -37,7 +37,9 @@ export function MainLayout() {
const isPaintingModule = activeModule === 'painting';
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isChatWorkspace = location.pathname === '/chat';
const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule;
const isInitializationSafeRoute = location.pathname === '/project-config'
|| location.pathname === '/project-plugins'
|| !isProgrammingModule;
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
if (!sidebarCollapsed) return;

View File

@@ -12,6 +12,7 @@ import {
FolderKanban,
LogOut,
Plus,
Puzzle,
Settings as SettingsIcon,
UserCircle2,
} from 'lucide-react';
@@ -653,6 +654,23 @@ 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} />

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { AlertTriangle, Database, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import type { CodingPluginEffectiveState } from '@/lib/coding-plugins';
import type { DataServiceInstanceState } from '../../../shared/data-service';
type Props = {
state: CodingPluginEffectiveState;
projectName: string;
data: DataServiceInstanceState | null;
pending: Record<string, true>;
onConfigure(collections: string[]): void | Promise<void>;
onReset(): void | Promise<void>;
onRemoveCollection(collection: string): void | Promise<void>;
onRemoveProject(): void | Promise<void>;
};
type DangerAction =
| { kind: 'collection'; target: string; title: string; confirmLabel: string }
| { kind: 'reset' | 'project'; target: string; title: string; confirmLabel: string };
function formatCount(value: number): string {
return new Intl.NumberFormat('zh-CN').format(value);
}
function formatBytes(value: number): string {
if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MB`;
if (value >= 1024) return `${Math.round(value / 1024)} KB`;
return `${value} B`;
}
export function DataServicePluginSettings({
state, projectName, data, pending, onConfigure, onReset, onRemoveCollection, onRemoveProject,
}: Props) {
const [collectionInput, setCollectionInput] = useState('');
const [danger, setDanger] = useState<DangerAction | null>(null);
const [confirmation, setConfirmation] = useState('');
const configuring = Boolean(pending['data-service:configure']);
const degraded = state === 'degraded';
const collections = collectionInput.split(',').map((value) => value.trim()).filter(Boolean);
const confirmDanger = async () => {
if (!danger || confirmation !== danger.target) return;
if (danger.kind === 'collection') await onRemoveCollection(danger.target);
else if (danger.kind === 'reset') await onReset();
else await onRemoveProject();
setDanger(null);
setConfirmation('');
};
if (state === 'configuration_required') {
return (
<section aria-labelledby="data-service-configure-heading" className="rounded-xl bg-surface-subtle p-4">
<h3 id="data-service-configure-heading" className="text-balance font-semibold"></h3>
<p className="mt-1 text-pretty text-sm text-muted-foreground"> collection </p>
<label htmlFor="data-service-collections" className="mt-4 block text-sm font-medium"> collection</label>
<Input id="data-service-collections" value={collectionInput} onChange={(event) => setCollectionInput(event.target.value)} placeholder="todos, settings" disabled={configuring} className="mt-2" />
<Button type="button" className="mt-3" disabled={configuring || collections.length === 0} onClick={() => void onConfigure(collections)}>
<Database className="mr-2 h-4 w-4" />{configuring ? '创建中…' : '创建开发数据空间'}
</Button>
</section>
);
}
if (degraded && !data) {
return <p role="status" className="rounded-xl bg-warning/10 p-4 text-pretty text-sm"></p>;
}
if ((state !== 'ready' && !degraded) || !data) return null;
return (
<section aria-labelledby="data-service-usage-heading" className="space-y-4">
{degraded ? <p role="status" className="rounded-xl bg-warning/10 p-4 text-pretty text-sm"></p> : null}
<div>
<h3 id="data-service-usage-heading" className="text-balance font-semibold"></h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground"></p><p className="mt-1 font-semibold tabular-nums">{formatCount(data.usage.document_count)} / {formatCount(data.limits.max_documents)}</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 tabular-nums">{formatBytes(data.usage.total_bytes)} / {formatBytes(data.limits.max_total_bytes)}</p></div>
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground">Collections</p><p className="mt-1 font-semibold tabular-nums">{formatCount(data.collections.length)} / {formatCount(data.limits.max_collections)}</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 tabular-nums">{formatCount(data.limits.mutations_per_minute)}</p></div>
</div>
</div>
<div className="space-y-2">
{data.collections.map((collection) => (
<div key={collection.name} className="flex min-h-10 items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2">
<span className="min-w-0"><span className="block truncate font-medium">{collection.name}</span><span className="block text-xs tabular-nums text-muted-foreground">{formatCount(collection.document_count)} · {formatBytes(collection.total_bytes)}</span></span>
<Button type="button" variant="ghost" size="icon" aria-label={`移除 collection ${collection.name}`} disabled={degraded || Boolean(pending[`data-service:collection:${collection.name}`])} onClick={() => { setDanger({ kind: 'collection', target: collection.name, title: '移除 collection', confirmLabel: '确认移除' }); setConfirmation(''); }}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
</div>
<div className="rounded-xl bg-destructive/5 p-4">
<h3 className="flex items-center gap-2 text-balance font-semibold text-destructive"><AlertTriangle className="h-4 w-4" /></h3>
<p className="mt-1 text-pretty text-sm text-muted-foreground"></p>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={degraded || Boolean(pending['data-service:reset'])} onClick={() => { setDanger({ kind: 'reset', target: projectName, title: '重置项目数据', confirmLabel: '确认重置' }); setConfirmation(''); }}></Button>
<Button type="button" variant="destructive" disabled={degraded || Boolean(pending['data-service:remove-project'])} onClick={() => { setDanger({ kind: 'project', target: projectName, title: '删除开发数据空间', confirmLabel: '确认删除' }); setConfirmation(''); }}></Button>
</div>
</div>
<Dialog open={Boolean(danger)} onOpenChange={(open) => { if (!open) { setDanger(null); setConfirmation(''); } }}>
<DialogContent>
<DialogHeader><DialogTitle className="text-balance">{danger?.title}</DialogTitle><DialogDescription className="text-pretty">{projectName}{danger?.target}</DialogDescription></DialogHeader>
<label htmlFor="data-service-danger-confirmation" className="text-sm font-medium"></label>
<Input id="data-service-danger-confirmation" value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" />
<DialogFooter><Button type="button" variant="outline" onClick={() => setDanger(null)}></Button><Button type="button" variant="destructive" disabled={!danger || confirmation !== danger.target} onClick={() => void confirmDanger()}>{danger?.confirmLabel}</Button></DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}

View File

@@ -66,6 +66,7 @@ const moduleAccessKeyById: Record<AiModuleId, ModuleAccessKey> = {
const PROGRAMMING_ROUTE_PREFIXES = [
'/project-config',
'/project-plugins',
'/makelore-home',
'/kangaroo',
'/subagents',

198
src/lib/coding-plugins.ts Normal file
View File

@@ -0,0 +1,198 @@
import { hostApiFetch } from '@/lib/host-api';
import type {
DataServiceCollectionRemoval,
DataServiceHostResult,
DataServiceInstanceRemoval,
DataServiceInstanceState,
} from '../../shared/data-service';
import type { PluginBillingMode } from '../../shared/coding-plugins';
export type CodingPluginEffectiveState =
| 'unavailable' | 'disabled' | 'identity_required' | 'authentication_required'
| 'configuration_required' | 'ready' | 'degraded';
export type PluginPolicyStatus = 'current' | 'stale' | 'unavailable';
export type PluginBackend =
| { status: 'not_required' | 'identity_required' | 'authentication_required' | 'unconfigured' | 'ready' }
| { status: 'degraded'; code: string; message: string; retryable: boolean; retry_after_seconds?: number };
export type PluginBilling = {
mode: PluginBillingMode;
availability: 'available' | 'unavailable';
notice: string;
pricingVersion?: number;
unitName?: string;
unitSize?: number;
ratePoints?: string;
minimumChargePoints?: string;
roundingMode?: 'ceil';
};
export type CodingPluginItem = {
id: string;
version: string;
displayName: string;
description: string;
enabled: boolean;
state: CodingPluginEffectiveState;
backend: PluginBackend;
skills: Array<{ id: string; assignedAgentIds: string[] }>;
capabilities: Array<{ id: string; operations: Array<{ id: string; billing: PluginBilling }> }>;
settingsSurface: string | null;
};
export type CodingPluginProject = {
schemaVersion: 1;
project: { localProjectId: string; durableProjectId: string | null };
policyStatus: PluginPolicyStatus;
items: CodingPluginItem[];
};
export type CodingPluginFetcher = <T>(path: string, init?: RequestInit) => Promise<T>;
function record(value: unknown, field: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${field} must be an object`);
return value as Record<string, unknown>;
}
function exact(value: Record<string, unknown>, keys: readonly string[], field: string): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
throw new Error(`${field} has unexpected fields`);
}
}
function string(value: unknown, field: string, max = 256): string {
if (typeof value !== 'string' || !value || value.length > max) throw new Error(`${field} is invalid`);
return value;
}
function optionalNumber(value: unknown, field: string): number | undefined {
if (value === undefined) return undefined;
if (!Number.isFinite(value) || (value as number) < 0) throw new Error(`${field} is invalid`);
return value as number;
}
function backend(value: unknown): PluginBackend {
const source = record(value, 'backend');
const status = string(source.status, 'backend.status', 32) as PluginBackend['status'];
if (['not_required', 'identity_required', 'authentication_required', 'unconfigured', 'ready'].includes(status)) {
exact(source, ['status'], 'backend');
return { status } as PluginBackend;
}
if (status !== 'degraded') throw new Error('backend.status is invalid');
const allowed = ['status', 'code', 'message', 'retryable', 'retry_after_seconds'];
if (Object.keys(source).some((key) => !allowed.includes(key))) throw new Error('backend has unexpected fields');
if (typeof source.retryable !== 'boolean') throw new Error('backend.retryable is invalid');
const retryAfter = optionalNumber(source.retry_after_seconds, 'backend.retry_after_seconds');
return {
status,
code: string(source.code, 'backend.code', 64),
message: string(source.message, 'backend.message', 160),
retryable: source.retryable,
...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter }),
};
}
function billing(value: unknown): PluginBilling {
const source = record(value, 'billing');
const allowed = ['mode', 'availability', 'notice', 'pricingVersion', 'unitName', 'unitSize', 'ratePoints', 'minimumChargePoints', 'roundingMode'];
if (Object.keys(source).some((key) => !allowed.includes(key))) throw new Error('billing has unexpected fields');
const mode = string(source.mode, 'billing.mode', 32) as PluginBillingMode;
if (!['included', 'platform_metered', 'external_account'].includes(mode)) throw new Error('billing.mode is invalid');
const availability = string(source.availability, 'billing.availability', 16);
if (availability !== 'available' && availability !== 'unavailable') throw new Error('billing.availability is invalid');
const result: PluginBilling = { mode, availability, notice: string(source.notice, 'billing.notice', 160) };
for (const key of ['pricingVersion', 'unitSize'] as const) {
const parsed = optionalNumber(source[key], `billing.${key}`);
if (parsed !== undefined) result[key] = parsed;
}
for (const key of ['unitName', 'ratePoints', 'minimumChargePoints'] as const) {
if (source[key] !== undefined) result[key] = string(source[key], `billing.${key}`, 64);
}
if (source.roundingMode !== undefined) {
if (source.roundingMode !== 'ceil') throw new Error('billing.roundingMode is invalid');
result.roundingMode = 'ceil';
}
return result;
}
function pluginItem(value: unknown): CodingPluginItem {
const source = record(value, 'plugin');
exact(source, ['id', 'version', 'displayName', 'description', 'enabled', 'state', 'backend', 'skills', 'capabilities', 'settingsSurface'], 'plugin');
if (typeof source.enabled !== 'boolean') throw new Error('plugin.enabled is invalid');
const state = string(source.state, 'plugin.state', 32) as CodingPluginEffectiveState;
if (!['unavailable', 'disabled', 'identity_required', 'authentication_required', 'configuration_required', 'ready', 'degraded'].includes(state)) throw new Error('plugin.state is invalid');
if (!Array.isArray(source.skills) || !Array.isArray(source.capabilities)) throw new Error('plugin arrays are invalid');
return {
id: string(source.id, 'plugin.id', 128), version: string(source.version, 'plugin.version', 64),
displayName: string(source.displayName, 'plugin.displayName'), description: string(source.description, 'plugin.description', 512),
enabled: source.enabled, state, backend: backend(source.backend),
skills: source.skills.map((value) => {
const skill = record(value, 'skill'); exact(skill, ['id', 'assignedAgentIds'], 'skill');
if (!Array.isArray(skill.assignedAgentIds)) throw new Error('skill.assignedAgentIds is invalid');
return { id: string(skill.id, 'skill.id', 128), assignedAgentIds: skill.assignedAgentIds.map((id) => string(id, 'agent id', 128)) };
}),
capabilities: source.capabilities.map((value) => {
const capability = record(value, 'capability'); exact(capability, ['id', 'operations'], 'capability');
if (!Array.isArray(capability.operations)) throw new Error('capability.operations is invalid');
return { id: string(capability.id, 'capability.id', 128), operations: capability.operations.map((value) => {
const operation = record(value, 'operation'); exact(operation, ['id', 'billing'], 'operation');
return { id: string(operation.id, 'operation.id', 128), billing: billing(operation.billing) };
}) };
}),
settingsSurface: source.settingsSurface === null ? null : string(source.settingsSurface, 'plugin.settingsSurface', 64),
};
}
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');
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');
return {
schemaVersion: 1,
project: { localProjectId: string(project.localProjectId, 'project.localProjectId', 128), durableProjectId: project.durableProjectId === null ? null : string(project.durableProjectId, 'project.durableProjectId', 128) },
policyStatus,
items: source.items.map(pluginItem),
};
}
const defaultFetch: CodingPluginFetcher = hostApiFetch;
export async function getCodingPlugins(projectId: string, fetcher = defaultFetch): Promise<CodingPluginProject> {
return parseCodingPluginProject(await fetcher(`/api/coding/plugins?projectId=${encodeURIComponent(projectId)}`));
}
export async function setCodingPluginEnabled(projectId: string, pluginId: string, enabled: boolean, fetcher = defaultFetch): Promise<CodingPluginProject> {
return parseCodingPluginProject(await fetcher(`/api/coding/plugins/${encodeURIComponent(pluginId)}`, {
method: 'PUT', body: JSON.stringify({ projectId, enabled }),
}));
}
function dataServiceResult<T>(value: unknown): DataServiceHostResult<T> {
const source = record(value, 'Data Service result');
const allowed = ['success', 'status', 'code', 'error', 'retryable', 'retry_after_seconds', 'context', 'data'];
if (Object.keys(source).some((key) => !allowed.includes(key)) || typeof source.success !== 'boolean'
|| !Number.isSafeInteger(source.status) || typeof source.retryable !== 'boolean') throw new Error('Data Service result is invalid');
return value as DataServiceHostResult<T>;
}
export async function inspectDataService(fetcher = defaultFetch) {
return dataServiceResult<DataServiceInstanceState>(await fetcher('/api/works/data-service/project'));
}
export async function configureDataService(collections: string[], fetcher = defaultFetch) {
return dataServiceResult<DataServiceInstanceState>(await fetcher('/api/works/data-service/project', { method: 'PUT', body: JSON.stringify({ collections }) }));
}
export async function resetDataService(fetcher = defaultFetch) {
return dataServiceResult<DataServiceInstanceState>(await fetcher('/api/works/data-service/project/reset?confirmed=true', { method: 'POST' }));
}
export async function removeDataServiceCollection(collection: string, fetcher = defaultFetch) {
return dataServiceResult<DataServiceCollectionRemoval>(await fetcher(`/api/works/data-service/project/collections/${encodeURIComponent(collection)}?confirmed=true`, { method: 'DELETE' }));
}
export async function removeDataServiceProject(fetcher = defaultFetch) {
return dataServiceResult<DataServiceInstanceRemoval>(await fetcher('/api/works/data-service/project?confirmed=true', { method: 'DELETE' }));
}

View File

@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertCircle, CheckCircle2, ChevronRight, CircleOff, Plug, RefreshCw, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataServicePluginSettings } from '@/components/plugins/data-service-settings';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import type { CodingPluginItem, CodingPluginProject, PluginBilling } from '@/lib/coding-plugins';
import { cn } from '@/lib/utils';
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import type { DataServiceInstanceState } from '../../../shared/data-service';
import { BUNDLED_CODING_PLUGIN_DEFINITIONS, type CodingPluginDefinition } from '../../../shared/coding-plugins';
const PLUGIN_SETTINGS_SURFACES = { 'data-service': DataServicePluginSettings } as const;
const definitions = new Map<string, CodingPluginDefinition>(BUNDLED_CODING_PLUGIN_DEFINITIONS.map((definition) => [definition.id, definition]));
const STATE_TEXT: Record<CodingPluginItem['state'], string> = {
unavailable: '暂不可用', disabled: '未启用', identity_required: '需要项目 ID',
authentication_required: '需要登录', configuration_required: '待配置', ready: '已就绪', degraded: '服务降级',
};
const MUTATION_TEXT = { read: '只读', write: '变更数据', destructive: '破坏性操作' } as const;
const PERMISSION_TEXT: Record<string, string> = {
'project.data.admin': '管理项目开发数据', 'project.data.configure': '配置项目数据空间',
'project.data.read': '读取项目开发数据', 'project.data.write': '写入项目开发数据',
};
function StateMark({ state }: { state: CodingPluginItem['state'] }) {
const Icon = state === 'ready' ? CheckCircle2 : state === 'disabled' ? CircleOff : AlertCircle;
return <span className="inline-flex items-center gap-1.5 text-sm font-medium"><Icon aria-hidden="true" className="h-4 w-4" />{STATE_TEXT[state]}</span>;
}
function billingText(billing: PluginBilling): string {
if (billing.availability === 'unavailable') return '计费策略暂不可用,相关调用已停用。';
if (billing.mode === 'included') return '当前包含,不按单次调用扣点';
if (billing.mode === 'external_account') return '由外部服务商计费,不计入 Token Point。';
const unit = billing.unitName ? `计量单位:${billing.unitName}` : '以服务端计量为准';
return `平台计量扣点。${unit},价格与回执以服务端为准。`;
}
type ViewProps = {
projectName: string;
projection: CodingPluginProject;
dataService: DataServiceInstanceState | null;
pending: Record<string, true>;
agentNames: Record<string, string>;
refreshError?: string | null;
onRefresh(): void | Promise<void>;
onSetEnabled(pluginId: string, enabled: boolean): void | Promise<void>;
onConfigure(collections: string[]): void | Promise<void>;
onReset(): void | Promise<void>;
onRemoveCollection(collection: string): void | Promise<void>;
onRemoveProject(): void | Promise<void>;
};
export function ProjectPluginsView(props: ViewProps) {
const navigate = useNavigate();
const [selectedId, setSelectedId] = useState(props.projection.items[0]?.id ?? null);
const [disableCandidate, setDisableCandidate] = useState<CodingPluginItem | null>(null);
const selected = props.projection.items.find(({ id }) => id === selectedId) ?? props.projection.items[0] ?? null;
const definition = selected ? definitions.get(selected.id) : undefined;
const billing = selected?.capabilities.flatMap(({ operations }) => operations.map(({ billing }) => billing))[0] ?? null;
const SettingsSurface = selected?.settingsSurface && selected.settingsSurface in PLUGIN_SETTINGS_SURFACES
? PLUGIN_SETTINGS_SURFACES[selected.settingsSurface as keyof typeof PLUGIN_SETTINGS_SURFACES]
: null;
const pendingToggle = selected ? Boolean(props.pending[`enabled:${props.projection.project.localProjectId}:${selected.id}`]) : false;
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>
<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)]">
<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')}>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-subtle"><Plug className="h-5 w-5 text-brand" /></span>
<span className="min-w-0 flex-1"><span className="block truncate font-semibold">{plugin.displayName}</span><span className="mt-1 block"><StateMark state={plugin.state} /></span></span><ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
))}
</section>
{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>}
</div>
<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>
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground"></p><p className="mt-1 font-semibold">v{definition?.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">{definition?.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}
<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 flex flex-wrap gap-2">{selected.capabilities.map((capability) => <Badge key={capability.id} variant="secondary">{capability.id}</Badge>)}</div>
<div className="mt-3 space-y-3">{definition?.tools.map((tool) => <div key={tool.name} className="rounded-xl bg-surface-subtle p-4"><div className="flex flex-wrap items-center gap-2"><code className="font-mono text-sm font-semibold">{tool.name}</code><Badge variant="outline">{MUTATION_TEXT[tool.mutation]}</Badge></div><p className="mt-2 text-pretty text-sm text-muted-foreground">{tool.description}</p><p className="mt-2 flex items-center gap-2 text-xs"><ShieldCheck className="h-4 w-4 text-brand" />{tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}</p></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>
<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>{billing ? <><p className="mt-2 text-pretty text-sm">{billingText(billing)}</p>{billing.mode === 'platform_metered' ? <p className="mt-1 text-pretty text-xs text-muted-foreground"></p> : null}</> : <p className="mt-2 text-pretty text-sm"></p>}</section>
{SettingsSurface && selected.enabled ? <section className="mt-7" aria-labelledby="plugin-settings-heading"><h3 id="plugin-settings-heading" className="mb-3 text-balance text-lg font-semibold"></h3><SettingsSurface state={selected.state} projectName={props.projectName} data={props.dataService} pending={props.pending} onConfigure={props.onConfigure} onReset={props.onReset} onRemoveCollection={props.onRemoveCollection} onRemoveProject={props.onRemoveProject} /></section> : null}
</article>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
<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 activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const config = useCodingWorkspaceStore((state) => state.config);
const projection = useCodingPluginsStore((state) => state.projection);
const dataService = useCodingPluginsStore((state) => state.dataService);
const pending = useCodingPluginsStore((state) => state.pending);
const loadState = useCodingPluginsStore((state) => state.loadState);
const error = useCodingPluginsStore((state) => state.error);
const 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]);
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>;
if (!currentProjection) return <div role="alert" className="p-6"><p className="text-pretty text-sm">{error ? ` ${error}` : ''}</p><Button className="mt-3" onClick={() => void codingPluginsStore.getState().load(activeProject.id)}></Button></div>;
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())} />;
}

View File

@@ -0,0 +1,124 @@
import { useStore } from 'zustand';
import { createStore, type StoreApi } from 'zustand/vanilla';
import {
configureDataService,
getCodingPlugins,
inspectDataService,
removeDataServiceCollection,
removeDataServiceProject,
resetDataService,
setCodingPluginEnabled,
type CodingPluginProject,
} from '@/lib/coding-plugins';
import type { DataServiceInstanceState } from '../../shared/data-service';
type Dependencies = {
list: typeof getCodingPlugins;
setEnabled: typeof setCodingPluginEnabled;
inspectDataService: typeof inspectDataService;
configureDataService: typeof configureDataService;
resetDataService: typeof resetDataService;
removeCollection: typeof removeDataServiceCollection;
removeProject: typeof removeDataServiceProject;
};
export type CodingPluginsState = {
projectId: string | null;
projection: CodingPluginProject | null;
dataService: DataServiceInstanceState | null;
loadState: 'idle' | 'loading' | 'ready' | 'error';
error: string | null;
pending: Record<string, true>;
load(projectId: string): Promise<void>;
setEnabled(projectId: string, pluginId: string, enabled: boolean): Promise<void>;
configure(collections: string[]): Promise<void>;
reset(): Promise<void>;
removeCollection(collection: string): Promise<void>;
removeProject(): Promise<void>;
};
export function createCodingPluginsStore(overrides: Partial<Dependencies> = {}): StoreApi<CodingPluginsState> {
const deps: Dependencies = {
list: getCodingPlugins, setEnabled: setCodingPluginEnabled, inspectDataService,
configureDataService, resetDataService, removeCollection: removeDataServiceCollection,
removeProject: removeDataServiceProject, ...overrides,
};
const flights = new Map<string, Promise<void>>();
const operation = (key: string, run: () => Promise<void>): Promise<void> => {
const existing = flights.get(key); if (existing) return existing;
let flight: Promise<void>;
flight = run().finally(() => {
flights.delete(key);
const pending = { ...store.getState().pending }; delete pending[key]; store.setState({ pending });
});
flights.set(key, flight); store.setState((state) => ({ pending: { ...state.pending, [key]: true } }));
return flight;
};
const store = createStore<CodingPluginsState>((set, get) => ({
projectId: null, projection: null, dataService: null, loadState: 'idle', error: null, pending: {},
load(projectId) {
return operation(`load:${projectId}`, async () => {
set({ loadState: 'loading', error: null });
try {
const projection = await deps.list(projectId);
let dataService = get().projectId === projectId ? get().dataService : null;
const item = projection.items.find(({ settingsSurface }) => settingsSurface === 'data-service');
if (item?.enabled && item.backend.status === 'ready') {
const result = await deps.inspectDataService();
if (result.success && result.data) dataService = result.data;
}
set({ projectId, projection, dataService, loadState: 'ready' });
} catch (error) {
set({ loadState: 'error', error: error instanceof Error ? error.message : String(error) });
throw error;
}
});
},
setEnabled(projectId, pluginId, enabled) {
return operation(`enabled:${projectId}:${pluginId}`, async () => {
const projection = await deps.setEnabled(projectId, pluginId, enabled);
set({
projectId, projection, error: null,
...(!enabled || get().projectId !== projectId ? { dataService: null } : {}),
});
});
},
configure(collections) {
return operation('data-service:configure', async () => {
const result = await deps.configureDataService(collections);
if (!result.success || !result.data) throw new Error(result.error || '开发数据空间创建失败');
set({ dataService: result.data, error: null });
if (get().projectId) await get().load(get().projectId as string);
});
},
reset() {
return operation('data-service:reset', async () => {
const result = await deps.resetDataService();
if (!result.success || !result.data) throw new Error(result.error || '开发数据重置失败');
set({ dataService: result.data });
});
},
removeCollection(collection) {
return operation(`data-service:collection:${collection}`, async () => {
const result = await deps.removeCollection(collection);
if (!result.success) throw new Error(result.error || '移除 collection 失败');
const inspected = await deps.inspectDataService();
if (inspected.success && inspected.data) set({ dataService: inspected.data });
});
},
removeProject() {
return operation('data-service:remove-project', async () => {
const result = await deps.removeProject();
if (!result.success) throw new Error(result.error || '删除开发数据空间失败');
set({ dataService: null });
if (get().projectId) await get().load(get().projectId as string);
});
},
}));
return store;
}
export const codingPluginsStore = createCodingPluginsStore();
export function useCodingPluginsStore<T>(selector: (state: CodingPluginsState) => T): T {
return useStore(codingPluginsStore, selector);
}