feat(plugins): merge Skill management into plugins

This commit is contained in:
inman committed 2026-09-07 18:36:33 +08:00
1 parent bb8a19eecf
commit ab9f889233
49 files changed
+461 -1218

No files matched your search

+14 -4
View File
@@ -38,6 +38,12 @@ function capabilityDescriptions(item: PluginWorkspaceItem): PluginCapabilityDesc
}
if (descriptions.length) return descriptions;
if (item.source === 'local') {
if (item.local?.kind === 'skill-only') {
return [{ title: null, description: '提供用户明确安装、可在本机使用的 Skill。' }];
}
if (item.local?.kind === 'mixed') {
return [{ title: null, description: '同时提供用户明确安装的本机 Skill 与扩展能力。' }];
}
return [{ title: null, description: '为智能体提供可在本机使用的扩展能力。' }];
}
if (item.source === 'retained') {
@@ -50,7 +56,11 @@ function capabilityDescriptions(item: PluginWorkspaceItem): PluginCapabilityDesc
}
function pluginIntroduction(item: PluginWorkspaceItem): string {
if (item.source === 'local') return '这是一项已安装在本机的插件能力,可供智能体在适用任务中使用。';
if (item.source === 'local') {
if (item.local?.kind === 'skill-only') return '这是用户自行安装在本机的 Skill,已统一收纳在插件模块中。';
if (item.local?.kind === 'mixed') return '这是用户自行安装在本机的 Skill 与插件扩展包。';
return '这是一项已安装在本机的插件能力,可供智能体在适用任务中使用。';
}
if (item.source === 'retained') return '这项插件能力仍保留在项目中,目前暂时无法使用。';
return item.official?.detail?.descriptionMarkdown
|| item.summary
@@ -133,7 +143,7 @@ function confirmationCopy(
case 'remove_local':
return {
title: `卸载${item.title}?`,
description: '插件会从本机移除,智能体将不能继续使用它。',
description: '这项能力会从本机移除,智能体将不能继续使用它。',
confirm: '确认卸载',
};
default:
@@ -179,7 +189,7 @@ export function PluginDetails(props: PluginDetailsProps) {
<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">
<DialogTitle className="text-balance text-2xl">{props.item.title}</DialogTitle>
<DialogDescription>了解它可以为智能体带来哪些能力。</DialogDescription>
<DialogDescription>了解这项插件或 Skill 可以为智能体带来哪些能力。</DialogDescription>
</DialogHeader>
<Button type="button" variant="ghost" size="icon" aria-label="关闭插件详情" onClick={props.onClose}>
<X className="h-4 w-4" />
@@ -188,7 +198,7 @@ export function PluginDetails(props: PluginDetailsProps) {
<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">
{pluginIntroduction(props.item)}
</p>
+65 -8
View File
@@ -3,6 +3,7 @@ import { AlertCircle, ArrowLeft, Puzzle } from 'lucide-react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import type { DataServiceInstanceState } from '../../../shared/data-service';
import { useAuthStore } from '@/stores/auth';
@@ -23,6 +24,7 @@ import { resolvePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './
import { PROJECT_SETTINGS_PLUGINS_PATH } from './legacy-plugin-redirect';
export type PluginWorkspaceSourceName = 'catalog' | 'library' | 'device' | 'project' | 'detail';
export type PluginWorkspaceTab = 'available' | 'mine';
export interface PluginWorkspaceSourceError {
source: PluginWorkspaceSourceName;
@@ -32,7 +34,7 @@ export interface PluginWorkspaceSourceError {
const SOURCE_ERROR_TITLE: Record<PluginWorkspaceSourceName, string> = {
catalog: '官方目录',
library: '账号插件库',
device: '本机插件包',
device: '本机插件与 Skill',
project: '当前项目插件',
detail: '插件详情',
};
@@ -87,11 +89,26 @@ function settledPluginState(item: PluginWorkspaceItem): string {
}
function pluginCardSummary(item: PluginWorkspaceItem): string {
if (item.source === 'local') return '为智能体提供可在本机使用的扩展能力。';
if (item.source === 'local') {
if (item.local?.kind === 'skill-only') return '本机安装的 Skill,可供项目智能体在适用任务中使用。';
if (item.local?.kind === 'mixed') return '本机安装的 Skill 与扩展,可供项目智能体使用。';
return '本机安装的扩展,可供项目智能体在适用任务中使用。';
}
if (item.source === 'retained') return '此前添加的插件能力目前暂不可用。';
return item.summary || '为智能体提供更多可用能力。';
}
function pluginKindLabel(item: PluginWorkspaceItem): string {
if (item.source === 'local') {
if (item.local?.kind === 'skill-only') return 'Skill';
if (item.local?.kind === 'mixed') return 'Skill + 插件';
return '插件';
}
const runtimeKind = item.official?.detail?.runtimeKind ?? item.official?.catalog?.runtimeKind;
if (runtimeKind === 'skill_only') return 'Skill';
return item.source === 'retained' ? '插件 / Skill' : '插件';
}
function PluginCard({
item,
onOpen,
@@ -112,7 +129,12 @@ function PluginCard({
<Puzzle className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h2 className="font-semibold text-foreground">{item.title}</h2>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-foreground">{item.title}</h2>
<span className="rounded-md bg-surface-subtle px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
{pluginKindLabel(item)}
</span>
</div>
<p className="mt-1 line-clamp-2 text-pretty text-sm leading-5 text-muted-foreground">
{pluginCardSummary(item)}
</p>
@@ -150,6 +172,8 @@ export interface PluginsViewProps {
sourceLoading: readonly PluginWorkspaceSourceName[];
dataService: DataServiceInstanceState | null;
dataServicePending: Record<string, true>;
activeTab: PluginWorkspaceTab;
onTabChange(tab: PluginWorkspaceTab): void;
onSelect(key: PluginWorkspaceItem['key'] | null): void;
onCommand(command: PluginWorkspaceCommand): void | Promise<void>;
isCommandPending(command: PluginWorkspaceCommand): boolean;
@@ -200,19 +224,36 @@ 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">
为你的智能体添加插件,拓展更多能力。
插件与 Skill 统一管理;云端能力在这里下发,本机安装的 Skill 也会集中显示。
</p>
</div>
</div>
</header> : null}
<Tabs
value={props.activeTab}
onValueChange={(value) => props.onTabChange(value as PluginWorkspaceTab)}
className={cn(!props.embedded && 'mt-6')}
>
<TabsList data-testid="plugin-tabs" aria-label="插件分类" className="grid w-full grid-cols-2">
<TabsTrigger value="available" className="gap-2" data-testid="plugin-tab-available">
可选
<span className="text-xs text-muted-foreground">{props.projection.counts.available}</span>
</TabsTrigger>
<TabsTrigger value="mine" className="gap-2" data-testid="plugin-tab-installed">
已安装
<span className="text-xs text-muted-foreground">{props.projection.counts.mine}</span>
</TabsTrigger>
</TabsList>
</Tabs>
{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">
{notice.message}
</p>
))}
{props.sourceLoading.length ? (
<p role="status" className="mt-4 text-sm text-muted-foreground">正在加载插件…</p>
<p role="status" className="mt-4 text-sm text-muted-foreground">正在加载插件与 Skill…</p>
) : null}
{props.sourceErrors.length ? (
<section aria-label="来源错误" className={cn('mt-4 grid gap-3', !props.embedded && 'md:grid-cols-2')}>
@@ -226,7 +267,7 @@ export function PluginsView(props: PluginsViewProps) {
) : null}
{props.projection.items.length ? (
<section aria-label="插件列表" className={props.embedded ? 'mt-1' : 'mt-6'}>
<section aria-label={props.activeTab === 'mine' ? '已安装能力列表' : '可选能力列表'} className="mt-4">
<div data-testid="plugin-list" className="space-y-3">
{props.projection.items.map((item) => (
<PluginCard
@@ -242,8 +283,14 @@ export function PluginsView(props: PluginsViewProps) {
) : (
<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">
{props.activeTab === 'mine' ? '暂时没有已安装能力' : '暂时没有可选能力'}
</h2>
<p className="mt-1 text-pretty text-sm text-muted-foreground">
{props.activeTab === 'mine'
? '从云端安装的插件和你在本机安装的 Skill 会显示在这里。'
: '云端发布新的插件或 Skill 后会显示在这里。'}
</p>
</section>
)}
@@ -325,6 +372,7 @@ export function Plugins({ embedded = false }: { embedded?: boolean } = {}) {
!activeProject && fallbackLocationSearch === queryString
);
const filters = searchResolution.filters;
const activeTab: PluginWorkspaceTab = filters.state === 'mine' ? 'mine' : 'available';
const currentProjectProjection = activeProject
&& projectProjection?.project.localProjectId === activeProject.id
? projectProjection
@@ -553,6 +601,15 @@ export function Plugins({ embedded = false }: { embedded?: boolean } = {}) {
sourceLoading={sourceLoading}
dataService={dataService}
dataServicePending={projectPending}
activeTab={activeTab}
onTabChange={(tab) => setFilters({
...filters,
scope: 'all',
source: 'all',
state: tab,
search: '',
selectedKey: null,
})}
onSelect={select}
onCommand={dispatch}
isCommandPending={commandPending}
+2 -2
View File
@@ -5,9 +5,9 @@ export const PROJECT_SETTINGS_PLUGINS_PATH = '/project-config/plugins';
export type LegacyPluginPath = '/plugin-marketplace' | '/my-plugins' | '/project-plugins';
const LEGACY_PLUGIN_REDIRECTS: Record<LegacyPluginPath, string> = {
'/plugin-marketplace': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=official`,
'/plugin-marketplace': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=all&state=available`,
'/my-plugins': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`,
'/project-plugins': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project`,
'/project-plugins': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project&state=mine`,
};
export function LegacyPluginRedirect({ from }: { from: LegacyPluginPath }) {
+15 -6
View File
@@ -147,6 +147,19 @@ export interface PluginWorkspaceProjection {
notices: readonly PluginWorkspaceNotice[];
}
/**
* The installed tab is device-oriented: it contains local packages, verified
* official Releases, built-in plugin deliveries, and retained project rows.
* An account Library entry that still needs its device Release stays in the
* available tab so its next action remains visible.
*/
export function isPluginWorkspaceItemInstalled(item: PluginWorkspaceItem): boolean {
if (item.source === 'local' || item.source === 'retained') return true;
if (item.delivery === 'system_included' || item.projectState === 'enabled') return true;
if (installed(item.official?.installation ?? null)) return true;
return isBundledOfficialPlugin(item) && item.delivery === 'account_acquired';
}
interface OfficialAccumulator {
pluginId: string;
catalog: MarketplaceCatalogItem | null;
@@ -441,13 +454,9 @@ function orderedUnique(values: readonly string[]): string[] {
function matchesState(item: PluginWorkspaceItem, state: Exclude<PluginWorkspaceState, 'all'>): boolean {
switch (state) {
case 'available':
return item.source === 'official'
&& (item.delivery === 'not_acquired' || item.delivery === 'account_removed')
&& !item.unavailable;
return item.source === 'official' && !isPluginWorkspaceItemInstalled(item);
case 'mine':
return item.source === 'local'
|| item.delivery === 'system_included'
|| item.delivery === 'account_acquired';
return isPluginWorkspaceItemInstalled(item);
case 'enabled':
return item.projectState === 'enabled' || item.localEnabled === true;
case 'update':
+7 -2
View File
@@ -28,15 +28,20 @@ export function parsePluginWorkspaceSearch(
search: URLSearchParams,
hasActiveProject: boolean,
): PluginWorkspaceFilters {
const defaultScope: PluginWorkspaceScope = hasActiveProject ? 'project' : 'all';
const defaultScope: PluginWorkspaceScope = 'all';
const requestedScope = search.get('scope');
const requestedSource = search.get('source');
const requestedState = search.get('state');
const parsedScope = member(requestedScope, SCOPES) ? requestedScope : defaultScope;
const parsedState = member(requestedState, STATES) ? requestedState : 'available';
return {
scope: parsedScope === 'project' && !hasActiveProject ? 'all' : parsedScope,
source: member(requestedSource, SOURCES) ? requestedSource : 'all',
state: member(requestedState, STATES) ? requestedState : 'all',
// Only the two product tabs are canonical. Historical filtered URLs land
// on the nearest deterministic tab without reintroducing hidden filters.
state: parsedState === 'mine' || parsedState === 'enabled' || parsedState === 'update'
? 'mine'
: 'available',
search: search.get('q')?.trim() ?? '',
selectedKey: pluginKey(search.get('plugin')),
};