Files
makelore/src/pages/Plugins/PluginDetails.tsx

446 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { AlertTriangle, ExternalLink, ShieldCheck, X } from 'lucide-react';
import { DataServicePluginSettings } from '@/components/plugins/data-service-settings';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { DataServiceInstanceState } from '../../../shared/data-service';
import type { PluginWorkspaceCommand, PluginWorkspaceItem } from './plugin-workspace-model';
interface DetailOperation {
capabilityId: string;
operationId: string;
executionMode: 'synchronous' | 'job' | null;
enabled: boolean | null;
mutation: 'read' | 'write' | 'destructive' | null;
description: string | null;
billing: {
mode: 'included' | 'platform_metered' | 'external_account';
availability: 'available' | 'unavailable' | null;
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 | number | null;
};
}
function detailOperations(item: PluginWorkspaceItem): DetailOperation[] {
const operations = new Map<string, DetailOperation>();
for (const operation of item.official?.detail?.operations ?? []) {
operations.set(`${operation.capabilityId}:${operation.operation}`, {
capabilityId: operation.capabilityId,
operationId: operation.operation,
executionMode: operation.executionMode,
enabled: operation.enabled,
mutation: null,
description: null,
billing: {
...operation.billing,
availability: null,
},
});
}
for (const capability of item.official?.project?.capabilities ?? []) {
for (const operation of capability.operations) {
const key = `${capability.id}:${operation.id}`;
const catalogOperation = operations.get(key);
operations.set(key, {
capabilityId: capability.id,
operationId: operation.id,
executionMode: catalogOperation?.executionMode ?? null,
enabled: catalogOperation?.enabled ?? null,
mutation: operation.tool?.mutation ?? null,
description: operation.tool?.description ?? null,
billing: {
mode: operation.billing.mode,
availability: operation.billing.availability,
status: null,
notice: operation.billing.notice,
entitlementScope: null,
unitName: operation.billing.unitName ?? null,
unitSize: operation.billing.unitSize ?? null,
ratePoints: operation.billing.ratePoints ?? null,
minimumChargePoints: operation.billing.minimumChargePoints ?? null,
roundingMode: operation.billing.roundingMode ?? null,
pricingVersion: operation.billing.pricingVersion ?? null,
},
});
}
}
return [...operations.values()];
}
function operationGroups(operations: readonly DetailOperation[]): Array<{
capabilityId: string;
operations: DetailOperation[];
}> {
const groups = new Map<string, DetailOperation[]>();
for (const operation of operations) {
const group = groups.get(operation.capabilityId) ?? [];
group.push(operation);
groups.set(operation.capabilityId, group);
}
return [...groups].map(([capabilityId, groupOperations]) => ({
capabilityId,
operations: groupOperations,
}));
}
function operationBillingMode(mode: DetailOperation['billing']['mode']): string {
if (mode === 'included') return '包含';
if (mode === 'platform_metered') return '按 Token Point';
return '外部账号';
}
const SOURCE_TEXT = {
official: '官方',
local: '本机',
retained: '配置保留',
} as const;
const DELIVERY_TEXT = {
system_included: '随 MakeLore 提供',
account_acquired: '账号已获取',
account_removed: '已从账号移除',
account_unknown: '账号状态未知',
local_installed: '本机已安装',
not_acquired: '尚未获取',
retained: '仅保留项目配置',
} as const;
const PROJECT_TEXT = {
enabled: '当前项目已启用',
disabled: '当前项目未启用',
unavailable: '当前项目暂不可用',
unknown: '当前项目状态未知',
not_applicable: '不属于项目启用范围',
} as const;
const PROJECT_POLICY_NOTICE = {
stale: '当前项目策略使用缓存,所示能力与价格可能不是最新状态。',
unavailable: '当前项目策略不可用,无法确认最新能力与价格。',
} as const;
function billingText(item: PluginWorkspaceItem): string {
if (item.billing === 'included') return '按平台包含,不按单次插件调用扣点。';
if (item.billing === 'token_point') return '按 Token Point 实际用量计费,价格与回执以服务端为准。';
if (item.billing === 'mixed') return '部分能力包含,部分能力按 Token Point 用量计费。';
if (item.billing === 'none') return '无 MakeLore 平台插件计费投影;外部服务或模型调用可能另行计费。';
return '当前没有可信的插件计费投影。';
}
function commandLabel(command: PluginWorkspaceCommand, title: string): string {
switch (command.kind) {
case 'acquire': return `免费获取${title}`;
case 'reacquire': return `重新免费获取${title}`;
case 'remove_from_library': return `从账号移除${title}`;
case 'install_stable': return `下载${title}稳定版`;
case 'install_beta': return `安装${title} Beta`;
case 'update_official': return `更新${title}设备包`;
case 'remove_official_device_package': return `删除${title}官方设备包`;
case 'enable_project': return `启用${title}到当前项目`;
case 'disable_project': return `从当前项目禁用${title}`;
case 'enable_local': return `本机全局启用${title}`;
case 'disable_local': return `本机全局停用${title}`;
case 'remove_local': return `移除本机包${title}`;
case 'sign_in': return `登录后免费获取${title}`;
case 'open_agent_assignment': return `分配${title}给伙伴`;
case 'open_settings': return `查看${title}插件设置`;
}
}
function destructive(command: PluginWorkspaceCommand): boolean {
return command.kind === 'remove_from_library'
|| command.kind === 'remove_official_device_package'
|| command.kind === 'disable_project'
|| command.kind === 'remove_local';
}
function confirmationCopy(
command: PluginWorkspaceCommand,
item: PluginWorkspaceItem,
projectName: string | null,
): { title: string; description: string; confirm: string } {
switch (command.kind) {
case 'remove_from_library':
return {
title: `从账号移除${item.title}`,
description: '这会移除账号插件库记录,不会自动删除已经下载的设备包或项目配置。',
confirm: '确认从账号移除',
};
case 'remove_official_device_package':
return {
title: `删除${item.title}的官方设备包?`,
description: '这只删除当前设备上的官方包,不会移除账号插件库或项目配置。',
confirm: '确认删除官方设备包',
};
case 'disable_project':
return {
title: `从当前项目禁用${item.title}`,
description: `插件能力会从项目“${projectName ?? '当前项目'}”停止,但插件数据与账号记录会保留。`,
confirm: '确认从当前项目禁用',
};
case 'remove_local':
return {
title: `移除本机包${item.title}`,
description: '这会从本机全局移除该包,新建或空闲的主伙伴与 worker 将不再加载它。',
confirm: '确认移除本机包',
};
default:
return { title: '', description: '', confirm: '' };
}
}
export interface PluginDetailsProps {
item: PluginWorkspaceItem;
activeProjectName: string | null;
dataService: DataServiceInstanceState | null;
dataServicePending: Record<string, true>;
isCommandPending(command: PluginWorkspaceCommand): boolean;
onClose(): void;
onCommand(command: PluginWorkspaceCommand): void | Promise<void>;
onConfigureDataService(collections: string[]): void | Promise<void>;
onResetDataService(): void | Promise<void>;
onRemoveDataServiceCollection(collection: string): void | Promise<void>;
onRemoveDataServiceProject(): void | Promise<void>;
}
export function PluginDetails(props: PluginDetailsProps) {
const [confirmation, setConfirmation] = useState<PluginWorkspaceCommand | null>(null);
const project = props.item.official?.project ?? null;
const detail = props.item.official?.detail ?? null;
const installation = props.item.official?.installation ?? null;
const operations = detailOperations(props.item);
const projectPolicyNotice = props.item.projectPolicyStatus === 'stale'
|| props.item.projectPolicyStatus === 'unavailable'
? PROJECT_POLICY_NOTICE[props.item.projectPolicyStatus]
: null;
const showDataService = project?.enabled
&& project.settingsSurface === 'data-service';
const confirmCopy = confirmation
? confirmationCopy(confirmation, props.item, props.activeProjectName)
: null;
const run = (command: PluginWorkspaceCommand) => {
if (destructive(command)) {
setConfirmation(command);
return;
}
void props.onCommand(command);
};
return (
<>
<Dialog open onOpenChange={(open) => { if (!open) props.onClose(); }}>
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-3xl overflow-y-auto p-0">
<div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-border/70 bg-background/95 px-5 py-4 backdrop-blur sm:px-6">
<DialogHeader className="min-w-0 text-left">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{SOURCE_TEXT[props.item.source]}</Badge>
{props.item.stale ? <Badge variant="warning"></Badge> : null}
{props.item.suspended ? <Badge variant="warning"></Badge> : null}
{props.item.retired ? <Badge variant="warning">退</Badge> : null}
{props.item.unavailable && !props.item.suspended && !props.item.retired
? <Badge variant="warning"></Badge> : null}
</div>
<DialogTitle className="text-balance text-2xl">{props.item.title}</DialogTitle>
<DialogDescription>
{props.item.publisher ? `${props.item.publisher} · ` : ''}
{props.item.version ? `版本 ${props.item.version}` : '版本未知'}
</DialogDescription>
</DialogHeader>
<Button type="button" variant="ghost" size="icon" aria-label="关闭插件详情" onClick={props.onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-8 px-5 pb-6 sm:px-6">
<section aria-labelledby="plugin-description-heading">
<h3 id="plugin-description-heading" className="text-lg font-semibold"></h3>
<p className="mt-2 whitespace-pre-wrap text-pretty text-sm leading-6 text-muted-foreground">
{detail?.descriptionMarkdown || props.item.summary || '暂无介绍。'}
</p>
</section>
<section aria-labelledby="plugin-status-heading">
<h3 id="plugin-status-heading" className="text-lg font-semibold"></h3>
<dl className="mt-3 grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-surface-subtle p-3">
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 text-sm font-semibold">{DELIVERY_TEXT[props.item.delivery]}</dd>
</div>
<div className="rounded-xl bg-surface-subtle p-3">
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 text-sm font-semibold">
{props.item.source === 'local'
? (props.item.localEnabled ? '本机全局已启用' : '本机全局已停用')
: installation?.version ? `官方包 ${installation.version}` : props.item.delivery === 'system_included' ? '随应用提供' : '未下载官方包'}
</dd>
</div>
<div className="rounded-xl bg-surface-subtle p-3">
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 text-sm font-semibold">{PROJECT_TEXT[props.item.projectState]}</dd>
</div>
</dl>
{props.item.deviceReason ? (
<p className="mt-3 rounded-xl bg-warning/10 p-3 text-pretty text-sm text-warning">
{installation?.version ? '旧设备版本已保留' : '设备包不可用'}{props.item.deviceReason}
</p>
) : null}
</section>
<section aria-labelledby="plugin-actions-heading">
<h3 id="plugin-actions-heading" className="text-lg font-semibold"></h3>
{props.item.commands.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{props.item.commands.map((command) => (
<Button
key={`${command.kind}:${'pluginId' in command ? command.pluginId : 'packageId' in command ? command.packageId : ''}`}
type="button"
variant={destructive(command) ? 'outline' : 'default'}
className="min-h-10"
disabled={props.isCommandPending(command)}
aria-label={commandLabel(command, props.item.title)}
onClick={() => run(command)}
>
{command.kind === 'open_agent_assignment' || command.kind === 'open_settings'
? <ExternalLink className="mr-2 h-4 w-4" /> : null}
{commandLabel(command, props.item.title)}
</Button>
))}
</div>
) : <p className="mt-2 text-sm text-muted-foreground"></p>}
</section>
<section aria-labelledby="plugin-capabilities-heading">
<h3 id="plugin-capabilities-heading" className="text-lg font-semibold"></h3>
{operations.length ? (
<div className="mt-3 space-y-3">
{operationGroups(operations).map((capability) => (
<article key={capability.capabilityId} className="rounded-xl border border-border/70 p-4">
<h4 className="font-mono text-sm font-semibold">{capability.capabilityId}</h4>
<ul className="mt-3 space-y-3">
{capability.operations.map((operation) => (
<li key={operation.operationId} className="rounded-lg bg-surface-subtle p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<code className="font-semibold">{operation.operationId}</code>
{operation.mutation
? <Badge variant="outline">{operation.mutation}</Badge>
: operation.executionMode
? <Badge variant="outline">{operation.executionMode === 'job' ? '异步任务' : '同步'}</Badge>
: <Badge variant="outline">SDK</Badge>}
{operation.enabled === false ? <Badge variant="warning"></Badge> : null}
{operation.billing.availability === 'unavailable'
|| operation.billing.status === 'billing_unavailable'
? <Badge variant="warning"></Badge> : null}
</div>
{operation.description ? <p className="mt-2 text-pretty text-muted-foreground">{operation.description}</p> : null}
<p className="mt-2 text-xs">{operation.billing.notice}</p>
<dl className="mt-2 grid gap-x-4 gap-y-1 text-xs text-muted-foreground sm:grid-cols-2">
<div><dt className="inline"></dt><dd className="inline">{operationBillingMode(operation.billing.mode)}</dd></div>
{operation.billing.entitlementScope ? <div><dt className="inline"></dt><dd className="inline">{operation.billing.entitlementScope}</dd></div> : null}
{operation.billing.unitName ? <div><dt className="inline"></dt><dd className="inline">{operation.billing.unitName}</dd></div> : null}
{operation.billing.unitSize !== null ? <div><dt className="inline"></dt><dd className="inline">{operation.billing.unitSize}</dd></div> : null}
{operation.billing.ratePoints !== null ? <div><dt className="inline"> Token Point</dt><dd className="inline">{operation.billing.ratePoints}</dd></div> : null}
{operation.billing.minimumChargePoints !== null ? <div><dt className="inline"></dt><dd className="inline">{operation.billing.minimumChargePoints}</dd></div> : null}
{operation.billing.pricingVersion !== null ? <div><dt className="inline"></dt><dd className="inline">{operation.billing.pricingVersion}</dd></div> : null}
{operation.billing.roundingMode === 'ceil' ? <div><dt className="inline"></dt><dd className="inline"></dd></div> : null}
</dl>
</li>
))}
</ul>
</article>
))}
</div>
) : props.item.local ? (
<div className="mt-3 rounded-xl bg-surface-subtle p-4 text-sm">
<p>{props.item.local.kind === 'skill-only' ? 'Skill' : props.item.local.kind === 'pi-extension' ? 'Pi extension' : 'Skill + Pi extension'}</p>
<p className="mt-1 text-muted-foreground">{props.item.local.skillEntries.length} Skill · {props.item.local.extensionEntries.length} extension</p>
{props.item.local.confirmedExecutableCode ? (
<p className="mt-3 flex gap-2 text-warning">
<AlertTriangle className="h-4 w-4 shrink-0" />
{props.item.local.kind === 'skill-only'
? '包含可执行 Skill 脚本;调用时可能使用当前桌面用户可用的文件、网络和进程权限。'
: '包含可执行代码,运行时拥有完整桌面权限。'}
</p>
) : null}
</div>
) : <p className="mt-2 text-sm text-muted-foreground"></p>}
{detail?.permissions.length ? (
<div className="mt-4 flex gap-2 rounded-xl bg-surface-subtle p-4 text-sm">
<ShieldCheck className="h-4 w-4 shrink-0 text-brand" />
<p>{detail.permissions.join('、')}</p>
</div>
) : null}
</section>
<section aria-labelledby="plugin-billing-heading">
<h3 id="plugin-billing-heading" className="text-lg font-semibold">Token Point </h3>
{projectPolicyNotice ? (
<p role="status" className="mt-3 flex gap-2 rounded-xl bg-warning/10 p-3 text-pretty text-sm text-warning">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{projectPolicyNotice}</span>
</p>
) : null}
<p className="mt-2 text-pretty text-sm text-muted-foreground">{billingText(props.item)}</p>
</section>
<section aria-labelledby="plugin-agents-heading">
<h3 id="plugin-agents-heading" className="text-lg font-semibold"></h3>
{props.item.source === 'local'
? <p className="mt-2 text-pretty text-sm text-muted-foreground"> Agent Skill worker </p>
: props.item.assignedAgentNames.length
? <div className="mt-3 flex flex-wrap gap-2">{props.item.assignedAgentNames.map((name) => <Badge key={name} variant="secondary">{name}</Badge>)}</div>
: <p className="mt-2 text-sm text-muted-foreground"></p>}
</section>
{showDataService ? (
<section id="plugin-settings" aria-labelledby="plugin-settings-heading">
<h3 id="plugin-settings-heading" className="mb-3 text-lg font-semibold"></h3>
<DataServicePluginSettings
state={project.state}
projectName={props.activeProjectName ?? '当前项目'}
data={props.dataService}
pending={props.dataServicePending}
onConfigure={props.onConfigureDataService}
onReset={props.onResetDataService}
onRemoveCollection={props.onRemoveDataServiceCollection}
onRemoveProject={props.onRemoveDataServiceProject}
/>
</section>
) : null}
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(confirmation)} onOpenChange={(open) => { if (!open) setConfirmation(null); }}>
<DialogContent role="alertdialog">
<DialogHeader>
<DialogTitle>{confirmCopy?.title}</DialogTitle>
<DialogDescription>{confirmCopy?.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setConfirmation(null)}></Button>
<Button type="button" variant="destructive" onClick={() => {
if (confirmation) void props.onCommand(confirmation);
setConfirmation(null);
}}>{confirmCopy?.confirm}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}