feat(project-config): simplify plugin and model drawers

This commit is contained in:
inman
2026-09-07 14:58:30 +08:00
parent 918f8f80dc
commit d278785da8
16 changed files with 583 additions and 709 deletions

View File

@@ -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),
});
}
}

View File

@@ -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 ?? '当前项目'}

View File

@@ -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}

View File

@@ -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()));
}

View File

@@ -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}