feat(project-config): simplify plugin and model drawers
This commit is contained in:
@@ -1,11 +1,44 @@
|
||||
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
|
||||
import type { ProductModelRef } from '@/types/coding-conversation';
|
||||
import type { ConversationThinkingLevel, ProductModelRef } from '@/types/coding-conversation';
|
||||
import { getImportedModelProfile } from '../../shared/imported-model-profile';
|
||||
|
||||
export interface CodingModelOption {
|
||||
key: string;
|
||||
accountId: string;
|
||||
modelId: string;
|
||||
label: string;
|
||||
availableThinkingLevels: ConversationThinkingLevel[] | null;
|
||||
}
|
||||
|
||||
const STANDARD_THINKING_LEVELS: readonly ConversationThinkingLevel[] = [
|
||||
'off',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
];
|
||||
const SERVER_REASONING_LEVELS = ['low', 'high', 'max'] as const;
|
||||
|
||||
function availableThinkingLevels(
|
||||
account: ProviderAccount,
|
||||
modelId: string,
|
||||
): ConversationThinkingLevel[] | null {
|
||||
const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId];
|
||||
if (serverCapability) {
|
||||
return [
|
||||
...(serverCapability.reasoningCanDisable ? ['off' as const] : []),
|
||||
...SERVER_REASONING_LEVELS.filter((level) => serverCapability.reasoningEfforts.includes(level)),
|
||||
];
|
||||
}
|
||||
|
||||
const profile = getImportedModelProfile(modelId)?.pi;
|
||||
if (!profile) return null;
|
||||
if (!profile.reasoning) return ['off'];
|
||||
if (!profile.thinkingLevelMap) return [...STANDARD_THINKING_LEVELS];
|
||||
return [
|
||||
...STANDARD_THINKING_LEVELS.filter((level) => profile.thinkingLevelMap?.[level] !== null),
|
||||
...(typeof profile.thinkingLevelMap.max === 'string' ? ['max' as const] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function codingModelKey(model: Pick<ProductModelRef, 'accountId' | 'modelId'>): string {
|
||||
@@ -51,6 +84,7 @@ export function buildCodingModelOptions(
|
||||
accountId: account.id,
|
||||
modelId,
|
||||
label: `${account.label}${vendor && vendor !== account.label ? ` · ${vendor}` : ''} / ${modelId}`,
|
||||
availableThinkingLevels: availableThinkingLevels(account, modelId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, ExternalLink, ShieldCheck, X } from 'lucide-react';
|
||||
import { CheckCircle2, ExternalLink, Puzzle, 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,
|
||||
@@ -12,157 +11,91 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { DataServiceInstanceState } from '../../../shared/data-service';
|
||||
import {
|
||||
isBundledOfficialPlugin,
|
||||
isProjectWideOfficialPlugin,
|
||||
type PluginWorkspaceCommand,
|
||||
type PluginWorkspaceItem,
|
||||
import type {
|
||||
PluginWorkspaceCommand,
|
||||
PluginWorkspaceItem,
|
||||
} from './plugin-workspace-model';
|
||||
import { isBundledOfficialPlugin } 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;
|
||||
};
|
||||
interface PluginCapabilityDescription {
|
||||
title: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
function capabilityDescriptions(item: PluginWorkspaceItem): PluginCapabilityDescription[] {
|
||||
const seen = new Set<string>();
|
||||
const descriptions: PluginCapabilityDescription[] = [];
|
||||
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,
|
||||
},
|
||||
});
|
||||
const title = operation.tool?.label.trim() || null;
|
||||
const description = operation.tool?.description.trim();
|
||||
if (!description) continue;
|
||||
const key = `${title ?? ''}:${description}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
descriptions.push({ title, description });
|
||||
}
|
||||
}
|
||||
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);
|
||||
if (descriptions.length) return descriptions;
|
||||
if (item.source === 'local') {
|
||||
return [{ title: null, description: '为智能体提供可在本机使用的扩展能力。' }];
|
||||
}
|
||||
return [...groups].map(([capabilityId, groupOperations]) => ({
|
||||
capabilityId,
|
||||
operations: groupOperations,
|
||||
}));
|
||||
if (item.source === 'retained') {
|
||||
return [{ title: null, description: '保留此前添加到项目的能力,恢复可用后可继续使用。' }];
|
||||
}
|
||||
return [{
|
||||
title: null,
|
||||
description: item.summary || '为智能体补充更多处理任务的能力。',
|
||||
}];
|
||||
}
|
||||
|
||||
function operationBillingMode(mode: DetailOperation['billing']['mode']): string {
|
||||
if (mode === 'included') return '包含';
|
||||
if (mode === 'platform_metered') return '按 Token Point';
|
||||
return '外部账号';
|
||||
function pluginIntroduction(item: PluginWorkspaceItem): string {
|
||||
if (item.source === 'local') return '这是一项已安装在本机的插件能力,可供智能体在适用任务中使用。';
|
||||
if (item.source === 'retained') return '这项插件能力仍保留在项目中,目前暂时无法使用。';
|
||||
return item.official?.detail?.descriptionMarkdown
|
||||
|| item.summary
|
||||
|| '为智能体补充更多处理任务的能力。';
|
||||
}
|
||||
|
||||
const SOURCE_TEXT = {
|
||||
official: '官方',
|
||||
local: '本机',
|
||||
retained: '配置保留',
|
||||
} as const;
|
||||
function usageDescription(item: PluginWorkspaceItem): string {
|
||||
if (item.unavailable) return '这项能力目前暂不可用,恢复后即可继续添加或使用。';
|
||||
if (item.projectState === 'enabled') return '已添加到当前项目,项目中的智能体可以按配置使用这些能力。';
|
||||
if (item.source === 'local') {
|
||||
return item.localEnabled
|
||||
? '已在本机启用,智能体可在适用任务中使用这些能力。'
|
||||
: '已安装在本机,启用后智能体即可使用这些能力。';
|
||||
}
|
||||
if (isBundledOfficialPlugin(item)) return '此插件随应用提供,添加到项目后即可使用。';
|
||||
if (item.delivery === 'account_acquired') return '已添加到你的插件库,安装或添加到项目后即可使用。';
|
||||
return '添加这项插件后,即可为智能体拓展对应能力。';
|
||||
}
|
||||
|
||||
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 commandText(command: PluginWorkspaceCommand): string {
|
||||
switch (command.kind) {
|
||||
case 'acquire': return '添加';
|
||||
case 'reacquire': return '重新添加';
|
||||
case 'remove_from_library': return '从插件库移除';
|
||||
case 'install_stable': return '安装';
|
||||
case 'install_beta': return '安装尝鲜版';
|
||||
case 'update_official': return '更新';
|
||||
case 'remove_official_device_package': return '从本机卸载';
|
||||
case 'enable_project': return '添加到项目';
|
||||
case 'disable_project': return '从项目移除';
|
||||
case 'enable_local': return '启用';
|
||||
case 'disable_local': return '停用';
|
||||
case 'remove_local': return '卸载';
|
||||
case 'sign_in': return '登录后添加';
|
||||
case 'open_agent_assignment': return '分配给智能体';
|
||||
case 'open_settings': 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}插件设置`;
|
||||
case 'enable_project': return `将${title}添加到项目`;
|
||||
case 'open_agent_assignment': return `将${title}分配给智能体`;
|
||||
case 'open_settings': return `打开${title}设置`;
|
||||
default: return `${commandText(command)}${title}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,27 +114,27 @@ function confirmationCopy(
|
||||
switch (command.kind) {
|
||||
case 'remove_from_library':
|
||||
return {
|
||||
title: `从账号移除${item.title}?`,
|
||||
description: '这会移除账号插件库记录,不会自动删除已经下载的设备包或项目配置。',
|
||||
confirm: '确认从账号移除',
|
||||
title: `从插件库移除${item.title}?`,
|
||||
description: '它将不再保留在你的插件库中,已添加到项目的配置不会自动变化。',
|
||||
confirm: '确认移除',
|
||||
};
|
||||
case 'remove_official_device_package':
|
||||
return {
|
||||
title: `删除${item.title}的官方设备包?`,
|
||||
description: '这只删除当前设备上的官方包,不会移除账号插件库或项目配置。',
|
||||
confirm: '确认删除官方设备包',
|
||||
title: `从本机卸载${item.title}?`,
|
||||
description: '插件将从当前设备卸载,你的插件库和项目配置仍会保留。',
|
||||
confirm: '确认卸载',
|
||||
};
|
||||
case 'disable_project':
|
||||
return {
|
||||
title: `从当前项目禁用${item.title}?`,
|
||||
description: `插件能力会从项目“${projectName ?? '当前项目'}”停止,但插件数据与账号记录会保留。`,
|
||||
confirm: '确认从当前项目禁用',
|
||||
title: `从当前项目移除${item.title}?`,
|
||||
description: `项目“${projectName ?? '当前项目'}”中的智能体将不能继续使用这项插件能力。`,
|
||||
confirm: '确认从项目移除',
|
||||
};
|
||||
case 'remove_local':
|
||||
return {
|
||||
title: `移除本机包${item.title}?`,
|
||||
description: '这会从本机全局移除该包,新建或空闲的主伙伴与 worker 将不再加载它。',
|
||||
confirm: '确认移除本机包',
|
||||
title: `卸载${item.title}?`,
|
||||
description: '插件会从本机移除,智能体将不能继续使用它。',
|
||||
confirm: '确认卸载',
|
||||
};
|
||||
default:
|
||||
return { title: '', description: '', confirm: '' };
|
||||
@@ -225,15 +158,8 @@ export interface PluginDetailsProps {
|
||||
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 capabilities = capabilityDescriptions(props.item);
|
||||
const showDataService = project?.enabled && project.settingsSurface === 'data-service';
|
||||
const confirmCopy = confirmation
|
||||
? confirmationCopy(confirmation, props.item, props.activeProjectName)
|
||||
: null;
|
||||
@@ -249,65 +175,52 @@ export function PluginDetails(props: PluginDetailsProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open onOpenChange={(open) => { if (!open) props.onClose(); }}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-3xl overflow-y-auto p-0">
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-2xl 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>
|
||||
<DialogDescription>了解它可以为智能体带来哪些能力。</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">
|
||||
<div className="space-y-7 px-5 pb-6 sm:px-6">
|
||||
<section aria-labelledby="plugin-description-heading">
|
||||
<h3 id="plugin-description-heading" className="text-lg font-semibold">介绍</h3>
|
||||
<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 || '暂无介绍。'}
|
||||
{pluginIntroduction(props.item)}
|
||||
</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}` : isBundledOfficialPlugin(props.item) ? '随应用提供' : '未下载官方包'}
|
||||
</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 aria-labelledby="plugin-capabilities-heading">
|
||||
<h3 id="plugin-capabilities-heading" className="text-lg font-semibold">主要能力</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{capabilities.map((capability, index) => (
|
||||
<article key={`${capability.title ?? 'capability'}:${capability.description}:${index}`} className="flex gap-3 rounded-xl bg-surface-subtle p-4">
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-brand-soft text-brand" aria-hidden="true">
|
||||
<Puzzle className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{capability.title ? <h4 className="text-sm font-semibold">{capability.title}</h4> : null}
|
||||
<p className={`${capability.title ? 'mt-1 ' : ''}text-pretty text-sm leading-6 text-muted-foreground`}>{capability.description}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="plugin-usage-heading">
|
||||
<h3 id="plugin-usage-heading" className="text-lg font-semibold">使用方式</h3>
|
||||
<p className="mt-2 flex gap-2 rounded-xl border border-border/70 p-4 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
|
||||
<span>{usageDescription(props.item)}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="plugin-actions-heading">
|
||||
<h3 id="plugin-actions-heading" className="text-lg font-semibold">可用操作</h3>
|
||||
<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) => (
|
||||
@@ -322,102 +235,16 @@ export function PluginDetails(props: PluginDetailsProps) {
|
||||
>
|
||||
{command.kind === 'open_agent_assignment' || command.kind === 'open_settings'
|
||||
? <ExternalLink className="mr-2 h-4 w-4" /> : null}
|
||||
{commandLabel(command, props.item.title)}
|
||||
{props.isCommandPending(command) ? '处理中…' : commandText(command)}
|
||||
</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">
|
||||
{isProjectWideOfficialPlugin(props.item) ? '生效范围' : '伙伴分配'}
|
||||
</h3>
|
||||
{isProjectWideOfficialPlugin(props.item)
|
||||
? <p className="mt-2 text-pretty text-sm text-muted-foreground">启用当前项目后,插件会自动提供给该项目的主伙伴,无需单独分配;子伙伴不会继承。</p>
|
||||
: 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>}
|
||||
) : <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>
|
||||
<h3 id="plugin-settings-heading" className="mb-3 text-lg font-semibold">使用设置</h3>
|
||||
<DataServicePluginSettings
|
||||
state={project.state}
|
||||
projectName={props.activeProjectName ?? '当前项目'}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, type FormEvent } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Puzzle, RefreshCw, Search } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Puzzle } from 'lucide-react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DataServiceInstanceState } from '../../../shared/data-service';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
@@ -14,57 +11,17 @@ import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { devicePackageStore, useDevicePackageStore } from '@/stores/device-packages';
|
||||
import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace';
|
||||
import { PluginDetails } from './PluginDetails';
|
||||
import { dispatchPluginWorkspaceCommand, settlePluginWorkspaceLoads } from './plugin-workspace-controller';
|
||||
import { dispatchPluginWorkspaceCommand } from './plugin-workspace-controller';
|
||||
import type {
|
||||
PluginWorkspaceCommand,
|
||||
PluginWorkspaceFilters,
|
||||
PluginWorkspaceItem,
|
||||
PluginWorkspaceProjection,
|
||||
PluginWorkspaceState,
|
||||
} from './plugin-workspace-model';
|
||||
import {
|
||||
buildPluginWorkspaceProjection,
|
||||
isBundledOfficialPlugin,
|
||||
isProjectWideOfficialPlugin,
|
||||
} from './plugin-workspace-model';
|
||||
import { buildPluginWorkspaceProjection, isBundledOfficialPlugin } from './plugin-workspace-model';
|
||||
import { resolvePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './plugin-workspace-query';
|
||||
import { PROJECT_SETTINGS_PLUGINS_PATH } from './legacy-plugin-redirect';
|
||||
|
||||
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 BILLING_TEXT = {
|
||||
included: '包含',
|
||||
token_point: '按 Token Point',
|
||||
mixed: '混合',
|
||||
none: '无平台插件计费投影',
|
||||
unknown: '计费未知',
|
||||
} as const;
|
||||
|
||||
const STATE_OPTIONS: ReadonlyArray<{ value: PluginWorkspaceState; label: string }> = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'available', label: '可获取' },
|
||||
{ value: 'mine', label: '我的' },
|
||||
{ value: 'enabled', label: '已启用' },
|
||||
{ value: 'update', label: '可更新' },
|
||||
{ value: 'unavailable', label: '不可用' },
|
||||
];
|
||||
|
||||
export type PluginWorkspaceSourceName = 'catalog' | 'library' | 'device' | 'project' | 'detail';
|
||||
|
||||
export interface PluginWorkspaceSourceError {
|
||||
@@ -80,81 +37,119 @@ const SOURCE_ERROR_TITLE: Record<PluginWorkspaceSourceName, string> = {
|
||||
detail: '插件详情',
|
||||
};
|
||||
|
||||
function cardSource(item: PluginWorkspaceItem): string {
|
||||
if (item.source === 'official') return '官方';
|
||||
if (item.source === 'local') return '本机';
|
||||
return '配置保留';
|
||||
const PRIMARY_PLUGIN_ACTIONS: readonly PluginWorkspaceCommand['kind'][] = [
|
||||
'sign_in',
|
||||
'acquire',
|
||||
'reacquire',
|
||||
'install_stable',
|
||||
'update_official',
|
||||
'enable_project',
|
||||
'enable_local',
|
||||
'install_beta',
|
||||
];
|
||||
|
||||
function primaryPluginCommand(item: PluginWorkspaceItem): PluginWorkspaceCommand | null {
|
||||
for (const kind of PRIMARY_PLUGIN_ACTIONS) {
|
||||
const command = item.commands.find((candidate) => candidate.kind === kind);
|
||||
if (command) return command;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function agentText(item: PluginWorkspaceItem): string {
|
||||
if (item.source === 'local') return '本机全局生效';
|
||||
if (isProjectWideOfficialPlugin(item)) return '随项目启用';
|
||||
const count = item.assignedAgentIds.length;
|
||||
return count ? `已分配 ${count} 位伙伴` : '尚未分配伙伴';
|
||||
function primaryPluginActionText(command: PluginWorkspaceCommand): string {
|
||||
switch (command.kind) {
|
||||
case 'sign_in': return '登录后添加';
|
||||
case 'acquire':
|
||||
case 'reacquire': return '添加';
|
||||
case 'install_stable': return '安装';
|
||||
case 'install_beta': return '安装尝鲜版';
|
||||
case 'update_official': return '更新';
|
||||
case 'enable_project': return '添加到项目';
|
||||
case 'enable_local': return '启用';
|
||||
default: return '使用';
|
||||
}
|
||||
}
|
||||
|
||||
function agentLabel(item: PluginWorkspaceItem): string {
|
||||
return isProjectWideOfficialPlugin(item) ? '生效范围' : '伙伴';
|
||||
function primaryPluginActionLabel(command: PluginWorkspaceCommand, title: string): string {
|
||||
if (command.kind === 'enable_project') return `将${title}添加到项目`;
|
||||
return `${primaryPluginActionText(command)}${title}`;
|
||||
}
|
||||
|
||||
function settledPluginState(item: PluginWorkspaceItem): string {
|
||||
if (item.unavailable) return '暂不可用';
|
||||
if (item.projectState === 'enabled') return '已添加到项目';
|
||||
if (item.source === 'local') return item.localEnabled ? '已安装' : '已停用';
|
||||
if (item.official?.installation?.version) return '已安装';
|
||||
if (isBundledOfficialPlugin(item)) return '随应用提供';
|
||||
if (item.delivery === 'account_acquired') return '已添加';
|
||||
if (item.delivery === 'retained') return '暂不可用';
|
||||
return '可添加';
|
||||
}
|
||||
|
||||
function pluginCardSummary(item: PluginWorkspaceItem): string {
|
||||
if (item.source === 'local') return '为智能体提供可在本机使用的扩展能力。';
|
||||
if (item.source === 'retained') return '此前添加的插件能力目前暂不可用。';
|
||||
return item.summary || '为智能体提供更多可用能力。';
|
||||
}
|
||||
|
||||
function PluginCard({
|
||||
item,
|
||||
grouped = false,
|
||||
onOpen,
|
||||
onCommand,
|
||||
isCommandPending,
|
||||
}: {
|
||||
item: PluginWorkspaceItem;
|
||||
grouped?: boolean;
|
||||
onOpen(trigger: HTMLButtonElement): void;
|
||||
onCommand(command: PluginWorkspaceCommand): void | Promise<void>;
|
||||
isCommandPending(command: PluginWorkspaceCommand): boolean;
|
||||
}) {
|
||||
const Title = grouped ? 'h3' : 'h2';
|
||||
const primaryCommand = primaryPluginCommand(item);
|
||||
const commandPending = primaryCommand ? isCommandPending(primaryCommand) : false;
|
||||
return (
|
||||
<article className="group flex min-h-64 flex-col rounded-2xl border border-border/70 bg-background p-5 shadow-soft transition-[border-color,box-shadow,transform] duration-150 hover:-translate-y-0.5 hover:border-brand/25 hover:shadow-float">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={item.source === 'local' ? 'secondary' : 'outline'}>{cardSource(item)}</Badge>
|
||||
<Badge variant="outline">{DELIVERY_TEXT[item.delivery]}</Badge>
|
||||
{item.updateAvailable ? <Badge variant="success">可更新</Badge> : null}
|
||||
{item.suspended ? <Badge variant="warning">已暂停</Badge> : null}
|
||||
{item.retired ? <Badge variant="warning">已退役</Badge> : null}
|
||||
{item.unavailable && !item.suspended && !item.retired ? <Badge variant="warning">不可用</Badge> : null}
|
||||
{item.stale ? <Badge variant="warning">缓存</Badge> : null}
|
||||
<article className="rounded-xl border border-foreground/15 bg-white p-3 transition-colors hover:border-brand/35 hover:bg-brand-soft/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-soft text-brand" aria-hidden="true">
|
||||
<Puzzle className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-semibold text-foreground">{item.title}</h2>
|
||||
<p className="mt-1 line-clamp-2 text-pretty text-sm leading-5 text-muted-foreground">
|
||||
{pluginCardSummary(item)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="outline" className="min-h-9" onClick={(event) => onOpen(event.currentTarget)} aria-label={`查看${item.title}详情`}>
|
||||
查看详情
|
||||
</Button>
|
||||
{primaryCommand ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-9"
|
||||
disabled={commandPending}
|
||||
aria-label={primaryPluginActionLabel(primaryCommand, item.title)}
|
||||
onClick={() => void onCommand(primaryCommand)}
|
||||
>
|
||||
{commandPending ? '处理中…' : primaryPluginActionText(primaryCommand)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="min-h-9" disabled aria-label={`${item.title}:${settledPluginState(item)}`}>
|
||||
{settledPluginState(item)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Title className="mt-4 text-balance text-xl font-semibold">{item.title}</Title>
|
||||
<p className="mt-2 line-clamp-3 text-pretty text-sm leading-6 text-muted-foreground">{item.summary}</p>
|
||||
<dl className="mt-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between gap-4"><dt className="text-muted-foreground">当前项目</dt><dd className="text-right font-medium">{PROJECT_TEXT[item.projectState]}</dd></div>
|
||||
<div className="flex justify-between gap-4"><dt className="text-muted-foreground">本机状态</dt><dd className="text-right font-medium">{item.source === 'local' ? (item.localEnabled ? '本机全局已启用' : '本机全局已停用') : item.official?.installation?.version ? `官方包 ${item.official.installation.version}` : isBundledOfficialPlugin(item) ? '随应用提供' : '未下载官方包'}</dd></div>
|
||||
<div className="flex justify-between gap-4"><dt className="text-muted-foreground">{agentLabel(item)}</dt><dd className="text-right font-medium">{agentText(item)}</dd></div>
|
||||
<div className="flex justify-between gap-4"><dt className="text-muted-foreground">计费</dt><dd className="text-right font-medium">{BILLING_TEXT[item.billing]}</dd></div>
|
||||
</dl>
|
||||
{item.deviceReason ? (
|
||||
<p className="mt-3 text-pretty text-xs leading-5 text-warning">
|
||||
{item.official?.installation?.version ? '旧设备版本已保留' : '设备包不可用'}:{item.deviceReason}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-auto min-h-10 w-full"
|
||||
onClick={(event) => onOpen(event.currentTarget)}
|
||||
aria-label={`查看${item.title}详情`}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PluginsViewProps {
|
||||
projection: PluginWorkspaceProjection;
|
||||
filters: PluginWorkspaceFilters;
|
||||
activeProjectId: string | null;
|
||||
activeProjectName: string | null;
|
||||
sourceErrors: readonly PluginWorkspaceSourceError[];
|
||||
sourceLoading: readonly PluginWorkspaceSourceName[];
|
||||
dataService: DataServiceInstanceState | null;
|
||||
dataServicePending: Record<string, true>;
|
||||
onFiltersChange(filters: PluginWorkspaceFilters): void;
|
||||
onRefresh(): void | Promise<void>;
|
||||
onSelect(key: PluginWorkspaceItem['key'] | null): void;
|
||||
onCommand(command: PluginWorkspaceCommand): void | Promise<void>;
|
||||
isCommandPending(command: PluginWorkspaceCommand): boolean;
|
||||
@@ -172,14 +167,6 @@ export function PluginsView(props: PluginsViewProps) {
|
||||
detailTriggerRef.current = trigger;
|
||||
props.onSelect(item.key);
|
||||
};
|
||||
const updateFilters = (patch: Partial<PluginWorkspaceFilters>) => {
|
||||
props.onFiltersChange({ ...props.filters, ...patch, selectedKey: null });
|
||||
};
|
||||
const search = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
updateFilters({ search: String(data.get('plugin-search') ?? '').trim() });
|
||||
};
|
||||
const closeDetails = () => {
|
||||
const trigger = detailTriggerRef.current;
|
||||
props.onSelect(null);
|
||||
@@ -193,13 +180,7 @@ export function PluginsView(props: PluginsViewProps) {
|
||||
data-testid="plugins-page"
|
||||
className={cn('w-full text-foreground', !props.embedded && 'mx-auto max-w-7xl')}
|
||||
>
|
||||
{props.embedded ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />刷新全部来源
|
||||
</Button>
|
||||
</div>
|
||||
) : <header className="flex flex-wrap items-start justify-between gap-4">
|
||||
{!props.embedded ? <header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{props.onBackToProjectSettings ? (
|
||||
<Button
|
||||
@@ -219,84 +200,11 @@ export function PluginsView(props: PluginsViewProps) {
|
||||
<p className="text-sm font-medium text-brand">项目配置</p>
|
||||
<h1 className="mt-1 text-balance text-3xl font-semibold tracking-[-0.03em]">插件</h1>
|
||||
<p className="mt-2 max-w-3xl text-pretty text-sm leading-6 text-muted-foreground">
|
||||
MakeLore 运营发布官方插件;本机 Skill 和 Pi extension 仅通过对话安装。获取与项目启用彼此独立;代码内置的项目级插件随项目启用,需要定向生效的插件还可分配伙伴。
|
||||
为你的智能体添加插件,拓展更多能力。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />刷新全部来源
|
||||
</Button>
|
||||
</header>}
|
||||
|
||||
<section aria-label="插件筛选" className={cn(props.embedded ? 'mt-4' : 'mt-6', 'rounded-2xl border border-border/70 bg-surface-subtle p-4')}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-[0.8fr_0.8fr_1fr_2fr_auto]">
|
||||
<label className="text-sm font-medium">
|
||||
范围
|
||||
<Select
|
||||
aria-label="范围"
|
||||
className="mt-1"
|
||||
value={props.filters.scope}
|
||||
onChange={(event) => updateFilters({ scope: event.currentTarget.value === 'project' ? 'project' : 'all' })}
|
||||
>
|
||||
<option value="all">全部插件</option>
|
||||
<option value="project" disabled={!props.activeProjectName}>当前项目</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="text-sm font-medium">
|
||||
来源
|
||||
<Select
|
||||
aria-label="来源"
|
||||
className="mt-1"
|
||||
value={props.filters.source}
|
||||
onChange={(event) => {
|
||||
const source = event.currentTarget.value;
|
||||
updateFilters({ source: source === 'official' || source === 'local' ? source : 'all' });
|
||||
}}
|
||||
>
|
||||
<option value="all">全部来源</option>
|
||||
<option value="official">官方</option>
|
||||
<option value="local">本机</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="text-sm font-medium">
|
||||
状态
|
||||
<Select
|
||||
aria-label="状态"
|
||||
className="mt-1"
|
||||
value={props.filters.state}
|
||||
onChange={(event) => {
|
||||
const state = STATE_OPTIONS.find(({ value }) => value === event.currentTarget.value)?.value ?? 'all';
|
||||
updateFilters({ state });
|
||||
}}
|
||||
>
|
||||
{STATE_OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}({props.projection.counts[value]})</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<form role="search" className="sm:col-span-2 xl:col-span-1" onSubmit={search}>
|
||||
<label className="text-sm font-medium" htmlFor="plugin-workspace-search">搜索插件</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
key={props.filters.search}
|
||||
id="plugin-workspace-search"
|
||||
name="plugin-search"
|
||||
type="search"
|
||||
aria-label="搜索插件"
|
||||
defaultValue={props.filters.search}
|
||||
placeholder="名称、简介、标签或 package ID"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" size="icon" aria-label="提交插件搜索">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</header> : null}
|
||||
|
||||
{props.projection.notices.map((notice) => (
|
||||
<p key={`${notice.kind}:${notice.message}`} role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">
|
||||
@@ -304,53 +212,38 @@ export function PluginsView(props: PluginsViewProps) {
|
||||
</p>
|
||||
))}
|
||||
{props.sourceLoading.length ? (
|
||||
<p role="status" className="mt-4 text-sm text-muted-foreground">
|
||||
正在刷新 {props.sourceLoading.map((source) => SOURCE_ERROR_TITLE[source]).join('、')}…
|
||||
</p>
|
||||
<p role="status" className="mt-4 text-sm text-muted-foreground">正在加载插件…</p>
|
||||
) : null}
|
||||
{props.sourceErrors.length ? (
|
||||
<section aria-label="来源错误" className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<section aria-label="来源错误" className={cn('mt-4 grid gap-3', !props.embedded && 'md:grid-cols-2')}>
|
||||
{props.sourceErrors.map((error) => (
|
||||
<p key={error.source} role="alert" className="flex gap-2 rounded-xl bg-warning/10 p-3 text-pretty text-sm">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span><strong>{SOURCE_ERROR_TITLE[error.source]}刷新失败。</strong> {error.message}</span>
|
||||
<span><strong>{SOURCE_ERROR_TITLE[error.source]}加载失败。</strong> {error.message}</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{props.projection.items.length ? (
|
||||
<section aria-label="插件列表" className="mt-6">
|
||||
{props.filters.scope === 'project' ? (
|
||||
<div className="space-y-8">
|
||||
{([
|
||||
{ id: 'project', label: '当前项目插件', items: props.projection.items.filter((item) => item.source === 'official') },
|
||||
{ id: 'local', label: '本机全局生效', items: props.projection.items.filter((item) => item.source === 'local') },
|
||||
{ id: 'retained', label: '配置保留', items: props.projection.items.filter((item) => item.source === 'retained') },
|
||||
] as const).map((group) => group.items.length ? (
|
||||
<section key={group.id} aria-labelledby={`plugin-group-${group.id}`}>
|
||||
<h2 id={`plugin-group-${group.id}`} className="text-sm font-semibold text-muted-foreground">{group.label}</h2>
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.items.map((item) => (
|
||||
<PluginCard key={item.key} item={item} grouped onOpen={(trigger) => openDetails(item, trigger)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{props.projection.items.map((item) => (
|
||||
<PluginCard key={item.key} item={item} onOpen={(trigger) => openDetails(item, trigger)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<section aria-label="插件列表" className={props.embedded ? 'mt-1' : 'mt-6'}>
|
||||
<div data-testid="plugin-list" className="space-y-3">
|
||||
{props.projection.items.map((item) => (
|
||||
<PluginCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
onOpen={(trigger) => openDetails(item, trigger)}
|
||||
onCommand={props.onCommand}
|
||||
isCommandPending={props.isCommandPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-8 rounded-2xl bg-surface-subtle p-8 text-center">
|
||||
<section className="mt-4 rounded-xl bg-surface-subtle p-8 text-center">
|
||||
<Puzzle className="mx-auto h-8 w-8 text-muted-foreground" />
|
||||
<h2 className="mt-3 text-balance font-semibold">没有符合筛选条件的插件</h2>
|
||||
<p className="mt-1 text-pretty text-sm text-muted-foreground">调整范围、来源、状态或搜索词后再试。</p>
|
||||
<h2 className="mt-3 text-balance font-semibold">暂时没有可用插件</h2>
|
||||
<p className="mt-1 text-pretty text-sm text-muted-foreground">有新的插件能力时会显示在这里。</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -638,15 +531,6 @@ export function Plugins({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
case 'open_settings': return false;
|
||||
}
|
||||
};
|
||||
const refresh = () => settlePluginWorkspaceLoads({
|
||||
catalog: async () => await pluginMarketplaceStore.getState().loadCatalog({
|
||||
limit: 24,
|
||||
...(filters.search ? { query: filters.search } : {}),
|
||||
}),
|
||||
...(accountKey ? { library: async () => await pluginMarketplaceStore.getState().loadLibrary() } : {}),
|
||||
device: async () => await devicePackageStore.getState().load(),
|
||||
...(activeProject ? { project: async () => await codingPluginsStore.getState().load(activeProject.id) } : {}),
|
||||
}).then(() => undefined);
|
||||
const dispatch = (command: PluginWorkspaceCommand) => safe(dispatchPluginWorkspaceCommand(command, {
|
||||
marketplace: pluginMarketplaceStore.getState(),
|
||||
device: devicePackageStore.getState(),
|
||||
@@ -663,15 +547,12 @@ export function Plugins({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
return (
|
||||
<PluginsView
|
||||
projection={projection}
|
||||
filters={filters}
|
||||
activeProjectId={activeProject?.id ?? null}
|
||||
activeProjectName={activeProject?.name ?? null}
|
||||
sourceErrors={sourceErrors}
|
||||
sourceLoading={sourceLoading}
|
||||
dataService={dataService}
|
||||
dataServicePending={projectPending}
|
||||
onFiltersChange={setFilters}
|
||||
onRefresh={refresh}
|
||||
onSelect={select}
|
||||
onCommand={dispatch}
|
||||
isCommandPending={commandPending}
|
||||
|
||||
@@ -65,18 +65,3 @@ export async function dispatchPluginWorkspaceCommand(
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceLoaders {
|
||||
catalog(): Promise<void>;
|
||||
library?: () => Promise<void>;
|
||||
device(): Promise<void>;
|
||||
project?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function settlePluginWorkspaceLoads(
|
||||
loaders: PluginWorkspaceLoaders,
|
||||
): Promise<PromiseSettledResult<void>[]> {
|
||||
const available = [loaders.catalog, loaders.library, loaders.device, loaders.project]
|
||||
.filter((load): load is () => Promise<void> => Boolean(load));
|
||||
return Promise.allSettled(available.map((load) => load()));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ type SkillInfo = {
|
||||
entries: SkillStructureEntry[];
|
||||
};
|
||||
|
||||
const THINKING_LEVEL_LABELS = {
|
||||
off: '关闭',
|
||||
minimal: '极简',
|
||||
low: '低',
|
||||
medium: '中等',
|
||||
high: '高',
|
||||
max: '最高',
|
||||
} as const;
|
||||
|
||||
function createCustomAgent(index: number, modelKey: string): CodingProjectAgent {
|
||||
const now = new Date().toISOString();
|
||||
const model = parseCodingModelKey(modelKey);
|
||||
@@ -58,8 +67,8 @@ function createCustomAgent(index: number, modelKey: string): CodingProjectAgent
|
||||
}
|
||||
|
||||
function ResourceCard({ id, icon, title, subtitle, onClick }: { id: string; icon: React.ReactNode; title: string; subtitle: string; onClick: () => void }) {
|
||||
return <button type="button" data-testid={`resource-card-${id}`} onClick={onClick} className="config-motion-resource surface-card motion-press rounded-2xl border border-border/70 bg-background p-3 text-left shadow-none hover:shadow-soft">
|
||||
<div className="flex items-center gap-2"><span className="flex h-8 w-8 items-center justify-center rounded-lg bg-surface-subtle text-brand [&>svg]:h-4 [&>svg]:w-4">{icon}</span><p className="text-sm font-semibold">{title}</p></div><p className="mt-1.5 truncate text-[11px] font-medium text-muted-foreground">{subtitle}</p>
|
||||
return <button type="button" data-testid={`resource-card-${id}`} onClick={onClick} className="config-motion-resource surface-card motion-press min-w-0 rounded-2xl border border-border/70 bg-background p-3 text-left shadow-none hover:shadow-soft">
|
||||
<div className="flex min-w-0 items-center gap-2"><span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-surface-subtle text-brand [&>svg]:h-4 [&>svg]:w-4">{icon}</span><p className="truncate text-sm font-semibold">{title}</p></div><p className="mt-1.5 truncate text-[11px] font-medium text-muted-foreground">{subtitle}</p>
|
||||
</button>;
|
||||
}
|
||||
|
||||
@@ -72,11 +81,11 @@ function PluginWorkspaceSheet({ open, onClose }: { open: boolean; onClose: () =>
|
||||
return <Sheet open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
|
||||
<SheetContent
|
||||
data-testid="project-plugins-sheet"
|
||||
className="glass-surface flex w-[min(1080px,96vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none"
|
||||
className="glass-surface flex w-[min(430px,94vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none"
|
||||
>
|
||||
<DrawerFrame
|
||||
title="插件"
|
||||
desc="在当前项目页查看插件、启用项目能力并管理伙伴分配;获取、设备安装和项目启用仍彼此独立,本机 Skill 和 Pi extension 仍仅通过对话安装。"
|
||||
desc="为你的智能体添加插件,拓展更多能力。"
|
||||
onClose={onClose}
|
||||
>
|
||||
<Plugins embedded />
|
||||
@@ -116,17 +125,26 @@ function ModelCard({ model }: { model: CodingModelOption }) {
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-soft text-brand" aria-hidden="true"><Cpu className="h-4 w-4" /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 truncate font-semibold" title={model.label}>{model.label}</p>
|
||||
<Badge className="shrink-0 border border-border/80 bg-surface-subtle text-foreground">已配置</Badge>
|
||||
<p className="min-w-0 break-all text-base font-semibold" title={model.modelId}>{model.modelId}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-medium text-muted-foreground">可选思考强度</span>
|
||||
{model.availableThinkingLevels === null
|
||||
? <span className="text-[11px] font-medium text-muted-foreground">进入对话后可查看</span>
|
||||
: model.availableThinkingLevels.length === 0
|
||||
|| model.availableThinkingLevels.every((level) => level === 'off')
|
||||
? <span className="text-[11px] font-medium text-muted-foreground">不支持调节</span>
|
||||
: model.availableThinkingLevels.map((level) => (
|
||||
<Badge key={level} variant="outline" className="bg-surface-subtle text-[11px] font-medium">
|
||||
{THINKING_LEVEL_LABELS[level]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs font-medium text-muted-foreground" title={model.accountId}>{model.accountId}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ModelList({ models }: { models: CodingModelOption[] }) {
|
||||
export function ModelList({ models }: { models: CodingModelOption[] }) {
|
||||
if (models.length === 0) {
|
||||
return <div data-testid="project-model-empty-state" className="rounded-xl border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">
|
||||
当前没有可用模型,请先在模型设置中完成配置。智能体的默认模型在智能体配置中设置。
|
||||
@@ -317,7 +335,7 @@ export function ProjectConfiguration() {
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">项目配置</h1>
|
||||
</div>
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理获取、启用与分配状态" onClick={openPluginWorkspace} /></section>
|
||||
<section data-testid="project-resource-row" className="grid grid-cols-4 gap-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="为智能体拓展更多能力" onClick={openPluginWorkspace} /></section>
|
||||
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">项目智能体</h2><p className="mt-1 text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建智能体" title="创建智能体" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型设置中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">当前项目还没有智能体,点击右上角的 + 创建。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}</section>
|
||||
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
|
||||
@@ -349,7 +367,7 @@ export function ProjectConfiguration() {
|
||||
submitLabel={activeAgent ? '保存智能体' : '创建智能体'}
|
||||
/>
|
||||
<Sheet open={drawerMode !== null} onOpenChange={(open) => { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}><SheetContent className="glass-surface flex w-[min(430px,94vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none">
|
||||
{drawerMode === 'models' ? <DrawerFrame title="可用模型" desc="查看已配置的模型;智能体默认模型在智能体配置中设置。" onClose={() => setDrawerMode(null)}><ModelList models={modelOptions} /></DrawerFrame> : null}
|
||||
{drawerMode === 'models' ? <DrawerFrame title="可用模型" desc="查看具体模型与可选思考强度。" onClose={() => setDrawerMode(null)}><ModelList models={modelOptions} /></DrawerFrame> : null}
|
||||
{drawerMode === 'knowledge' ? <DrawerFrame title="知识文件" desc="文件保存在当前项目的 knowledge/ 目录中。" onClose={() => setDrawerMode(null)}><input ref={knowledgeInputRef} type="file" className="sr-only" onChange={(event) => { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} /><Button className="w-full border border-foreground/15 bg-brand-soft text-foreground" onClick={() => knowledgeInputRef.current?.click()}><Upload className="mr-2 h-4 w-4" />上传知识文件</Button><div className="mt-5 space-y-3">{knowledge.length ? knowledge.map((file) => <div key={file} className="rounded-lg border border-foreground/15 bg-white p-3 font-semibold">{file}</div>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium">当前还没有知识文件。</div>}</div></DrawerFrame> : null}
|
||||
{drawerMode === 'skills' && activeSkill ? <DrawerFrame title={`${activeSkill.name} · 技能详情`} desc="查看技能目录结构和主文件 SKILL.md。" onClose={() => setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"><SkillDetail skill={activeSkill} /></DrawerFrame> : null}
|
||||
{drawerMode === 'skills' && !activeSkill ? <DrawerFrame title="可用技能" desc="查看技能目录与主文件;在智能体配置中启用所需技能。" onClose={() => setDrawerMode(null)}><div className="space-y-3">{skills.length > 0 ? skills.map((skill) => <button key={skill.id} type="button" data-testid={`skill-card-${skill.id}`} aria-label={`查看技能详情:${skill.name}`} onClick={() => setActiveSkillId(skill.id)} className="w-full rounded-lg border border-foreground/15 bg-white p-3 text-left transition hover:border-brand/40 hover:bg-brand-soft/30"><div className="flex items-start gap-3"><Blocks className="mt-0.5 h-4 w-4 shrink-0 text-brand" /><span className="min-w-0 flex-1"><span className="block font-semibold">{skill.name}</span><span className="mt-1 block text-sm text-muted-foreground">{skill.description}</span><span className="mt-2 block text-[11px] font-medium text-brand">查看目录与 SKILL.md</span></span><ArrowLeft className="mt-0.5 h-4 w-4 shrink-0 rotate-180 text-muted-foreground" aria-hidden="true" /></div></button>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">当前没有可查看的技能。</div>}</div></DrawerFrame> : null}
|
||||
|
||||
Reference in New Issue
Block a user