446 lines
22 KiB
TypeScript
446 lines
22 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|