feat(plugins): merge Skill management into plugins
This commit is contained in:
1 parent
bb8a19eecf
commit
ab9f889233
49 files changed
+461
-1218
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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')),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user