diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml05-plugin-center-6c1e9a42.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml05-plugin-center-6c1e9a42.md new file mode 100644 index 0000000..dc49cf6 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml05-plugin-center-6c1e9a42.md @@ -0,0 +1,58 @@ +# Task: ML-05 Project Plugin Center + +## Identity + +- Task ID: 20260827-plugin-ml05-plugin-center-6c1e9a42 +- Mode: Feature +- Branch: codex/20260827-plugin-ml05-plugin-center-6c1e9a42-plugin-ml05-plugin-center +- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml05-plugin-center-6c1e9a42 +- Base commit: b6d9e6156fdc98aa792045692cc25fdce993a531 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Add the project-scoped `/project-plugins` route and sidebar entry without changing Main authority or ML-01–04 source. +- Add the strict Renderer coding-plugin parser/client, shared store, list/detail Plugin Center, and code-owned Data Service settings surface. +- Cover selection/configuration separation, effective states, capability/permission/agent assignment, billing copy, retained disable semantics, and typed destructive actions. + +## Intent And Constraints + +- Main's `CodingPluginProject` projection remains the authority for effective state, billing, backend availability, and project identity. +- Enabling changes plugin selection only; Data Service configuration is always an explicit subsequent action through the existing typed routes. +- No Renderer credential, policy-join, or plugin-host authority was added. No dependency or new animation was introduced. + +## Outcome + +- Implemented bounded parsing and typed Host API clients for plugin projection, selection, and Data Service inspection/configuration/removal. +- Implemented a coalescing store that prevents duplicate pending work, retains same-project data on degraded refresh, and never shows an old projection after switching projects. +- Implemented accessible available/enabled/disabled, identity/auth/configure/ready/degraded states; capability, permission, partner assignment, backend, version, billing, usage/limit, and shared-wallet language; and retained-data disable confirmation. +- Implemented typed confirmations for destructive actions using the real collection or project target. Degraded Data Service keeps the last usage structure visible but disables destructive controls. +- Added the project route and project-scoped navigation while preserving module and initialization gates. + +### UI skill influence + +| Before | After | +| --- | --- | +| No Plugin Center surface | Existing surface/ring/button primitives reused with concentric radii and no new page-load animation | +| Plugin state could rely on color/icon alone | Every state has visible text, semantic status/alert messaging, keyboard focus, and disabled semantics | +| Dynamic usage values had no presentation contract | Counts and byte usage use `tabular-nums`; headings/descriptions use `text-balance`/`text-pretty`; controls retain at least 40px hit areas | + +## Verification + +- Red phase: focused unit tests initially failed on missing owned modules; later capability/degraded and cross-project Data Service isolation assertions failed until the corresponding behavior was implemented. +- `pnpm exec vitest run tests/unit/coding-plugins-client.test.ts tests/unit/coding-plugins-store.test.ts tests/unit/project-plugins-page.test.tsx tests/unit/data-service-plugin-settings.test.tsx tests/unit/main-layout-module-gate.test.tsx tests/unit/main-layout-sidebar-peek.test.tsx tests/unit/sidebar-session-buckets.test.ts tests/unit/coding-plugin-composition.test.ts tests/unit/coding-plugins-routes.test.ts tests/unit/data-service-client.test.ts tests/unit/data-service-routes.test.ts tests/unit/data-service-server-registration.test.ts tests/unit/auth-store.test.ts --maxWorkers=1` — 12 files, 72 tests passed. +- `pnpm exec playwright test tests/e2e/project-plugins.spec.ts --config=playwright.config.ts` — 1 test passed. +- `pnpm typecheck` — passed. +- `pnpm lint:check` — 0 errors; it continues to report the 5 pre-existing `Home`/`Makelore` warnings. +- `pnpm build:vite` — passed; existing Vite dynamic-import and chunk-size warnings remain. +- `git diff --check` — passed. +- Delivery boundary: exact base `b6d9e6156fdc98aa792045692cc25fdce993a531`; one task commit directly on that base (exact immutable commit ID is recorded in the handoff because a commit cannot contain its own hash); worktree clean after commit. + +## Follow-ups + +- Full cross-platform packaged release validation remains coordinator-level work; the target Electron E2E ran successfully on Windows in this worktree. + +## Promotion Candidates + +- None recorded. diff --git a/src/App.tsx b/src/App.tsx index 3b7f9fd..76f61bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { )} > } /> + } /> } /> } /> } /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index c4a1804..0e0aa5d 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -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; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 3c8ce28..4ae164a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 ) : null} + {activeProject ? ( + + ) : null} ) : isLearningModule ? ( diff --git a/src/components/plugins/data-service-settings.tsx b/src/components/plugins/data-service-settings.tsx new file mode 100644 index 0000000..8f7c834 --- /dev/null +++ b/src/components/plugins/data-service-settings.tsx @@ -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; + onConfigure(collections: string[]): void | Promise; + onReset(): void | Promise; + onRemoveCollection(collection: string): void | Promise; + onRemoveProject(): void | Promise; +}; + +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(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 ( +
+

创建开发数据空间

+

启用插件不会创建云端数据。请明确填写初始 collection 后再创建。

+ + setCollectionInput(event.target.value)} placeholder="todos, settings" disabled={configuring} className="mt-2" /> + +
+ ); + } + + if (degraded && !data) { + return

后端暂时不可用。已保留页面结构和上次用量,这不代表项目未配置。

; + } + + if ((state !== 'ready' && !degraded) || !data) return null; + + return ( +
+ {degraded ?

后端暂时不可用。已保留页面结构和上次用量,这不代表项目未配置。

: null} +
+

用量与固定限额

+
+

文档

{formatCount(data.usage.document_count)} / {formatCount(data.limits.max_documents)}

+

存储

{formatBytes(data.usage.total_bytes)} / {formatBytes(data.limits.max_total_bytes)}

+

Collections

{formatCount(data.collections.length)} / {formatCount(data.limits.max_collections)}

+

每分钟变更

{formatCount(data.limits.mutations_per_minute)}

+
+
+
+ {data.collections.map((collection) => ( +
+ {collection.name}{formatCount(collection.document_count)} 个文档 · {formatBytes(collection.total_bytes)} + +
+ ))} +
+
+

危险操作

+

这些操作会删除云端数据,与“禁用插件”不同。

+
+ + +
+
+ { if (!open) { setDanger(null); setConfirmation(''); } }}> + + {danger?.title}项目“{projectName}”的真实目标是“{danger?.target}”。请输入该目标以确认。 + + setConfirmation(event.target.value)} autoComplete="off" /> + + + +
+ ); +} diff --git a/src/lib/ai-modules.ts b/src/lib/ai-modules.ts index 3384b50..f1cf13e 100644 --- a/src/lib/ai-modules.ts +++ b/src/lib/ai-modules.ts @@ -66,6 +66,7 @@ const moduleAccessKeyById: Record = { const PROGRAMMING_ROUTE_PREFIXES = [ '/project-config', + '/project-plugins', '/makelore-home', '/kangaroo', '/subagents', diff --git a/src/lib/coding-plugins.ts b/src/lib/coding-plugins.ts new file mode 100644 index 0000000..c1973bf --- /dev/null +++ b/src/lib/coding-plugins.ts @@ -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 = (path: string, init?: RequestInit) => Promise; + +function record(value: unknown, field: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${field} must be an object`); + return value as Record; +} + +function exact(value: Record, 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 { + return parseCodingPluginProject(await fetcher(`/api/coding/plugins?projectId=${encodeURIComponent(projectId)}`)); +} + +export async function setCodingPluginEnabled(projectId: string, pluginId: string, enabled: boolean, fetcher = defaultFetch): Promise { + return parseCodingPluginProject(await fetcher(`/api/coding/plugins/${encodeURIComponent(pluginId)}`, { + method: 'PUT', body: JSON.stringify({ projectId, enabled }), + })); +} + +function dataServiceResult(value: unknown): DataServiceHostResult { + 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; +} + +export async function inspectDataService(fetcher = defaultFetch) { + return dataServiceResult(await fetcher('/api/works/data-service/project')); +} +export async function configureDataService(collections: string[], fetcher = defaultFetch) { + return dataServiceResult(await fetcher('/api/works/data-service/project', { method: 'PUT', body: JSON.stringify({ collections }) })); +} +export async function resetDataService(fetcher = defaultFetch) { + return dataServiceResult(await fetcher('/api/works/data-service/project/reset?confirmed=true', { method: 'POST' })); +} +export async function removeDataServiceCollection(collection: string, fetcher = defaultFetch) { + return dataServiceResult(await fetcher(`/api/works/data-service/project/collections/${encodeURIComponent(collection)}?confirmed=true`, { method: 'DELETE' })); +} +export async function removeDataServiceProject(fetcher = defaultFetch) { + return dataServiceResult(await fetcher('/api/works/data-service/project?confirmed=true', { method: 'DELETE' })); +} diff --git a/src/pages/ProjectPlugins/index.tsx b/src/pages/ProjectPlugins/index.tsx new file mode 100644 index 0000000..91bd260 --- /dev/null +++ b/src/pages/ProjectPlugins/index.tsx @@ -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(BUNDLED_CODING_PLUGIN_DEFINITIONS.map((definition) => [definition.id, definition])); + +const STATE_TEXT: Record = { + unavailable: '暂不可用', disabled: '未启用', identity_required: '需要项目 ID', + authentication_required: '需要登录', configuration_required: '待配置', ready: '已就绪', degraded: '服务降级', +}; + +const MUTATION_TEXT = { read: '只读', write: '变更数据', destructive: '破坏性操作' } as const; +const PERMISSION_TEXT: Record = { + '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 ; +} + +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; + agentNames: Record; + refreshError?: string | null; + onRefresh(): void | Promise; + onSetEnabled(pluginId: string, enabled: boolean): void | Promise; + onConfigure(collections: string[]): void | Promise; + onReset(): void | Promise; + onRemoveCollection(collection: string): void | Promise; + onRemoveProject(): void | Promise; +}; + +export function ProjectPluginsView(props: ViewProps) { + const navigate = useNavigate(); + const [selectedId, setSelectedId] = useState(props.projection.items[0]?.id ?? null); + const [disableCandidate, setDisableCandidate] = useState(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 ( +
+
+

项目插件

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

+ +
+ {props.projection.policyStatus === 'stale' ?

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

: null} + {props.refreshError ?

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

: null} +
+
+ {props.projection.items.map((plugin) => ( + + ))} +
+ {selected ? ( +
+

{selected.displayName}

{selected.description}

+ {selected.enabled ? : } +
+
+

发布者

MakeLore

+

包版本

{selected.version}

+

合同版本

v{definition?.contractVersion ?? '—'}

+

后端

{definition?.requiresBackend ? '需要' : '不需要'}

+
+ {selected.state === 'identity_required' ? : null} + {selected.state === 'authentication_required' ? : null} +

能力与权限

+
{selected.capabilities.map((capability) => {capability.id})}
+
{definition?.tools.map((tool) =>
{tool.name}{MUTATION_TEXT[tool.mutation]}

{tool.description}

{tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}

)}
+
+

伙伴分配

+
{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => {props.agentNames[id] ?? '未知伙伴'}) :

尚未分配给任何伙伴。

}
+
+

用量与计费

{billing ? <>

{billingText(billing)}

{billing.mode === 'platform_metered' ?

钱包流水由权益所有者查看,当前页面不扩大财务权限。

: null} :

计费策略暂不可用,相关调用已停用。

}
+ {SettingsSurface && selected.enabled ?

配置

: null} +
+ ) :

当前没有可用插件。

} +
+ { if (!open) setDisableCandidate(null); }}>禁用{disableCandidate?.displayName}?禁用后,伙伴工具和预览访问会立即停止;云端数据会保留,不会被删除。 +
+ ); +} + +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

请先选择一个项目。

; + const currentProjection = projection?.project.localProjectId === activeProject.id ? projection : null; + if (!currentProjection && loadState === 'loading') return

正在读取项目插件…

; + if (!currentProjection) return

无法读取项目插件。{error ? ` ${error}` : ''}

; + const safe = (promise: Promise) => promise.catch((reason) => { + toast.error(reason instanceof Error ? reason.message : String(reason)); + }); + return safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={(pluginId, enabled) => safe(codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled))} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} />; +} diff --git a/src/stores/coding-plugins.ts b/src/stores/coding-plugins.ts new file mode 100644 index 0000000..114683d --- /dev/null +++ b/src/stores/coding-plugins.ts @@ -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; + load(projectId: string): Promise; + setEnabled(projectId: string, pluginId: string, enabled: boolean): Promise; + configure(collections: string[]): Promise; + reset(): Promise; + removeCollection(collection: string): Promise; + removeProject(): Promise; +}; + +export function createCodingPluginsStore(overrides: Partial = {}): StoreApi { + const deps: Dependencies = { + list: getCodingPlugins, setEnabled: setCodingPluginEnabled, inspectDataService, + configureDataService, resetDataService, removeCollection: removeDataServiceCollection, + removeProject: removeDataServiceProject, ...overrides, + }; + const flights = new Map>(); + const operation = (key: string, run: () => Promise): Promise => { + const existing = flights.get(key); if (existing) return existing; + let flight: Promise; + 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((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(selector: (state: CodingPluginsState) => T): T { + return useStore(codingPluginsStore, selector); +} diff --git a/tests/e2e/project-plugins.spec.ts b/tests/e2e/project-plugins.spec.ts new file mode 100644 index 0000000..c78b3b7 --- /dev/null +++ b/tests/e2e/project-plugins.spec.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test.describe('Project Plugin Center', () => { + test('enables selection without configuring Data Service or duplicating project IDs', async ({ launchElectronApp }) => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-plugins-e2e-')); + const app = await launchElectronApp({ skipSetup: true }); + try { + await app.evaluate(({ ipcMain }, selectedPath) => { + ipcMain.removeHandler('dialog:open'); + ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] })); + }, projectPath); + const page = await getStableWindow(app); + await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('sidebar-create-project').click(); + await page.getByRole('button', { name: '选择路径' }).click(); + await page.getByRole('button', { name: '确认创建' }).click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + + await app.evaluate(({ ipcMain }) => { + const state = { enabled: false, dataServiceCalls: 0, localProjectId: '' }; + (globalThis as typeof globalThis & { __projectPluginE2E?: typeof state }).__projectPluginE2E = state; + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { + const requestPath = request.path ?? ''; + if (requestPath.startsWith('/api/works/data-service/')) { + state.dataServiceCalls += 1; + return { ok: false, error: { message: 'Enable must not configure Data Service' } }; + } + if (requestPath.startsWith('/api/coding/plugins')) { + if (request.method === 'PUT') state.enabled = true; + else state.localProjectId = new URL(requestPath, 'https://makelore.local').searchParams.get('projectId') ?? ''; + return { ok: true, data: { status: 200, ok: true, json: { + schemaVersion: 1, + project: { localProjectId: state.localProjectId, durableProjectId: '11111111-1111-4111-8111-111111111111' }, + policyStatus: 'current', + items: [{ + id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供数据。', + enabled: state.enabled, state: state.enabled ? 'configuration_required' : 'disabled', backend: { status: 'unconfigured' }, + skills: [{ id: 'data-service', assignedAgentIds: [] }], + capabilities: [{ id: 'data-service.control', operations: [{ id: 'inspect', billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' } }] }], + settingsSurface: 'data-service', + }], + } } }; + } + return { ok: false, error: { message: `Unexpected Host API request: ${requestPath}` } }; + }); + }); + + await page.getByTestId('sidebar-nav-project-plugins').click(); + await expect(page.getByTestId('project-plugins-page')).toBeVisible(); + await expect(page.getByText('当前包含,不按单次调用扣点')).toBeVisible(); + const localProjectId = await app.evaluate(() => ( + (globalThis as typeof globalThis & { __projectPluginE2E?: { localProjectId: string } }).__projectPluginE2E?.localProjectId + )); + expect(localProjectId).toBeTruthy(); + await expect(page.getByText(localProjectId as string)).toHaveCount(0); + await expect(page.getByText('11111111-1111-4111-8111-111111111111')).toHaveCount(0); + await page.getByRole('button', { name: '启用开发数据服务' }).click(); + await expect(page.getByRole('article').getByText('待配置')).toBeVisible(); + await expect(page.getByRole('button', { name: '创建开发数据空间' })).toBeDisabled(); + const dataServiceCalls = await app.evaluate(() => ( + (globalThis as typeof globalThis & { __projectPluginE2E?: { dataServiceCalls: number } }).__projectPluginE2E?.dataServiceCalls + )); + expect(dataServiceCalls).toBe(0); + } finally { + await closeElectronApp(app); + await rm(projectPath, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/coding-plugins-client.test.ts b/tests/unit/coding-plugins-client.test.ts new file mode 100644 index 0000000..cabe9c3 --- /dev/null +++ b/tests/unit/coding-plugins-client.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + configureDataService, + getCodingPlugins, + parseCodingPluginProject, + setCodingPluginEnabled, +} from '@/lib/coding-plugins'; + +const PROJECT_ID = 'local-project'; + +function projection() { + return { + schemaVersion: 1, + project: { localProjectId: PROJECT_ID, durableProjectId: null }, + policyStatus: 'current', + items: [{ + id: 'makelore.data-service', + version: '1.0.0', + displayName: '开发数据服务', + description: '项目数据', + enabled: false, + state: 'disabled', + backend: { status: 'unconfigured' }, + skills: [{ id: 'data-service', assignedAgentIds: [] }], + capabilities: [{ + id: 'data-service.control', + operations: [{ + id: 'inspect', + billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' }, + }], + }], + settingsSurface: 'data-service', + }], + }; +} + +describe('coding plugins Renderer client', () => { + it('strictly parses the bounded Main projection', () => { + expect(parseCodingPluginProject(projection())).toEqual(projection()); + expect(() => parseCodingPluginProject({ ...projection(), owner: 'secret' })).toThrow(); + expect(() => parseCodingPluginProject({ ...projection(), items: [{ ...projection().items[0], state: 'installed' }] })).toThrow(); + }); + + it('uses the local project handle exactly once and enable does not configure', async () => { + const fetcher = vi.fn() + .mockResolvedValueOnce(projection()) + .mockResolvedValueOnce({ ...projection(), items: [{ ...projection().items[0], enabled: true, state: 'configuration_required' }] }); + + await getCodingPlugins(PROJECT_ID, fetcher); + await setCodingPluginEnabled(PROJECT_ID, 'makelore.data-service', true, fetcher); + + expect(fetcher).toHaveBeenNthCalledWith(1, '/api/coding/plugins?projectId=local-project'); + expect(fetcher).toHaveBeenNthCalledWith(2, '/api/coding/plugins/makelore.data-service', { + method: 'PUT', + body: JSON.stringify({ projectId: PROJECT_ID, enabled: true }), + }); + expect(fetcher.mock.calls.flat().join(' ')).not.toContain('/api/works/data-service/project'); + }); + + it('configures Data Service only through its existing typed route', async () => { + const response = { + success: true, + status: 200, + code: null, + error: null, + retryable: false, + data: { + instance_id: 'instance-1', + project_id: '11111111-1111-4111-8111-111111111111', + collections: [], + usage: { document_count: 0, total_bytes: 0 }, + limits: { + max_collections: 20, + max_documents: 1000, + max_total_bytes: 20971520, + max_document_bytes: 65536, + list_default_limit: 50, + list_max_limit: 100, + list_max_data_bytes: 1048576, + mutations_per_minute: 120, + }, + created_at: '2026-08-27T00:00:00Z', + updated_at: '2026-08-27T00:00:00Z', + }, + }; + const fetcher = vi.fn().mockResolvedValue(response); + + await expect(configureDataService(['todos'], fetcher)).resolves.toEqual(response); + expect(fetcher).toHaveBeenCalledWith('/api/works/data-service/project', { + method: 'PUT', + body: JSON.stringify({ collections: ['todos'] }), + }); + }); +}); diff --git a/tests/unit/coding-plugins-store.test.ts b/tests/unit/coding-plugins-store.test.ts new file mode 100644 index 0000000..96e934b --- /dev/null +++ b/tests/unit/coding-plugins-store.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCodingPluginsStore } from '@/stores/coding-plugins'; + +function projection(enabled = false, projectId = 'local-project') { + return { + schemaVersion: 1 as const, + project: { localProjectId: projectId, durableProjectId: null }, + policyStatus: 'current' as const, + items: [{ + id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '项目数据', + enabled, state: enabled ? 'configuration_required' as const : 'disabled' as const, + backend: { status: 'unconfigured' as const }, skills: [{ id: 'data-service', assignedAgentIds: [] }], + capabilities: [], settingsSurface: 'data-service', + }], + }; +} + +describe('coding plugins store', () => { + it('coalesces duplicate loads and mutations while retaining the last projection on failure', async () => { + let resolveLoad!: (value: ReturnType) => void; + const list = vi.fn(() => new Promise>((resolve) => { resolveLoad = resolve; })); + const setEnabled = vi.fn().mockResolvedValue(projection(true)); + const store = createCodingPluginsStore({ list, setEnabled }); + + const first = store.getState().load('local-project'); + const second = store.getState().load('local-project'); + resolveLoad(projection()); + await Promise.all([first, second]); + expect(list).toHaveBeenCalledOnce(); + + await Promise.all([ + store.getState().setEnabled('local-project', 'makelore.data-service', true), + store.getState().setEnabled('local-project', 'makelore.data-service', true), + ]); + expect(setEnabled).toHaveBeenCalledOnce(); + expect(store.getState().projection?.items[0].enabled).toBe(true); + + list.mockRejectedValueOnce(new Error('offline')); + await expect(store.getState().load('other-project')).rejects.toThrow('offline'); + expect(store.getState().projection?.project.localProjectId).toBe('local-project'); + expect(store.getState().loadState).toBe('error'); + }); + + it('clears project-scoped Data Service data when another project loads', async () => { + const store = createCodingPluginsStore({ list: vi.fn().mockResolvedValue(projection(false, 'other-project')) }); + store.setState({ + projectId: 'local-project', + dataService: { + instance_id: 'instance-1', project_id: 'cloud-project', collections: [], + usage: { document_count: 0, total_bytes: 0 }, + limits: { max_collections: 20, max_documents: 1000, max_total_bytes: 20971520, max_document_bytes: 65536, list_default_limit: 50, list_max_limit: 100, list_max_data_bytes: 1048576, mutations_per_minute: 120 }, + created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z', + }, + }); + await store.getState().load('other-project'); + expect(store.getState().dataService).toBeNull(); + }); +}); diff --git a/tests/unit/data-service-plugin-settings.test.tsx b/tests/unit/data-service-plugin-settings.test.tsx new file mode 100644 index 0000000..f5078ee --- /dev/null +++ b/tests/unit/data-service-plugin-settings.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { DataServicePluginSettings } from '@/components/plugins/data-service-settings'; + +const ready = { + instance_id: 'instance-1', project_id: 'cloud-project', + collections: [{ name: 'todos', document_count: 3, total_bytes: 2048, created_at: '2026-08-27T00:00:00Z' }], + usage: { document_count: 3, total_bytes: 2048 }, + limits: { max_collections: 20, max_documents: 1000, max_total_bytes: 20971520, max_document_bytes: 65536, list_default_limit: 50, list_max_limit: 100, list_max_data_bytes: 1048576, mutations_per_minute: 120 }, + created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z', +}; + +describe('Data Service plugin settings', () => { + it('requires explicit collection input before configuration and prevents duplicate submit', () => { + const onConfigure = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('初始 collection'), { target: { value: 'todos, settings' } }); + fireEvent.click(screen.getByRole('button', { name: '创建开发数据空间' })); + expect(onConfigure).toHaveBeenCalledWith(['todos', 'settings']); + }); + + it('shows fixed usage and limits with tabular numerals and typed destructive confirmation', () => { + const onRemoveCollection = vi.fn(); + render(); + + expect(screen.getByText('3 / 1,000')).toHaveClass('tabular-nums'); + expect(screen.getByText('2 KB / 20 MB')).toHaveClass('tabular-nums'); + fireEvent.click(screen.getByRole('button', { name: '移除 collection todos' })); + expect(screen.getByRole('dialog')).toHaveTextContent('todos'); + const input = screen.getByLabelText('输入确认目标'); + fireEvent.change(input, { target: { value: 'wrong' } }); + expect(screen.getByRole('button', { name: '确认移除' })).toBeDisabled(); + fireEvent.change(input, { target: { value: 'todos' } }); + fireEvent.click(screen.getByRole('button', { name: '确认移除' })); + expect(onRemoveCollection).toHaveBeenCalledWith('todos'); + }); + + it('retains last usage structure in degraded state and disables destructive actions', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('保留页面结构和上次用量'); + expect(screen.getByText('3 / 1,000')).toBeVisible(); + expect(screen.getByRole('button', { name: '移除 collection todos' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '重置数据' })).toBeDisabled(); + }); +}); diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 95a6daf..6aedef6 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -105,6 +105,13 @@ describe('MainLayout module isolation', () => { expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false'); }); + it('allows the project Plugin Center to resolve identity before initialization', () => { + renderLayout('/project-plugins'); + + expect(screen.getByTestId('route-content')).toBeVisible(); + expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument(); + }); + it('keeps the programming workspace padding compensation separate from painting', () => { renderLayout('/chat'); diff --git a/tests/unit/project-plugins-page.test.tsx b/tests/unit/project-plugins-page.test.tsx new file mode 100644 index 0000000..82db5ca --- /dev/null +++ b/tests/unit/project-plugins-page.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { ProjectPluginsView } from '@/pages/ProjectPlugins'; +import type { CodingPluginProject } from '@/lib/coding-plugins'; + +function projection(): CodingPluginProject { + return { + schemaVersion: 1, + project: { localProjectId: 'local-only-id', durableProjectId: '11111111-1111-4111-8111-111111111111' }, + policyStatus: 'current', + items: [{ + id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供 JSON 数据。', + enabled: false, state: 'disabled', backend: { status: 'unconfigured' }, + skills: [{ id: 'data-service', assignedAgentIds: ['agent-1'] }], + capabilities: [{ id: 'data-service.control', operations: [{ id: 'inspect', billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' } }] }], + settingsSurface: 'data-service', + }], + }; +} + +describe('Project Plugin Center', () => { + it('uses available/enabled language, text states, exact included copy, and never renders project IDs', () => { + const onSetEnabled = vi.fn(); + render(); + + expect(screen.getByRole('heading', { name: '项目插件' })).toHaveClass('text-balance'); + expect(screen.getAllByText('未启用').length).toBeGreaterThan(0); + expect(screen.getByText('当前包含,不按单次调用扣点')).toBeVisible(); + expect(screen.getByText('小明')).toBeVisible(); + expect(screen.getByText('data-service.control')).toBeVisible(); + expect(screen.queryByText('local-only-id')).not.toBeInTheDocument(); + expect(screen.queryByText('11111111-1111-4111-8111-111111111111')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '启用开发数据服务' })); + expect(onSetEnabled).toHaveBeenCalledWith('makelore.data-service', true); + }); + + it('keeps the last project structure visible when refresh is degraded', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('刷新失败'); + expect(screen.getByRole('article')).toBeVisible(); + }); + + it('explains unknown billing and keeps invocation unavailable', () => { + const value = projection(); + value.items[0].state = 'unavailable'; + value.items[0].capabilities[0].operations[0].billing = { mode: 'platform_metered', availability: 'unavailable', notice: 'pricing unavailable' }; + render(); + expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).toBeVisible(); + expect(screen.getByRole('button', { name: '启用开发数据服务' })).toBeDisabled(); + }); +});