feat: consolidate plugin navigation

This commit is contained in:
inman
2026-08-31 16:30:17 +08:00
parent 38f85f6b5e
commit b3f4166f21
12 changed files with 227 additions and 69 deletions

View File

@@ -32,9 +32,7 @@ const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({
const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component })));
const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component })));
const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component })));
const ProjectPlugins = lazy(() => import('./pages/ProjectPlugins').then(({ ProjectPlugins: component }) => ({ default: component })));
const PluginMarketplace = lazy(() => import('./pages/PluginMarketplace').then(({ PluginMarketplace: component }) => ({ default: component })));
const MyPlugins = lazy(() => import('./pages/MyPlugins').then(({ MyPlugins: component }) => ({ default: component })));
const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component })));
const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component })));
const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component })));
@@ -446,9 +444,9 @@ function App() {
)}
>
<Route path="/project-config" element={<ProjectConfiguration />} />
<Route path="/project-plugins" element={<ProjectPlugins />} />
<Route path="/project-plugins" element={<Navigate to="/project-config?resource=plugins" replace />} />
<Route path="/plugin-marketplace" element={<PluginMarketplace />} />
<Route path="/my-plugins" element={<MyPlugins />} />
<Route path="/my-plugins" element={<Navigate to="/plugin-marketplace?tab=mine" replace />} />
<Route path="/makelore-home" element={<Home />} />
<Route path="/kangaroo" element={<Navigate to="/project-config" replace />} />
<Route path="/subagents" element={<Navigate to="/project-config" replace />} />

View File

@@ -12,8 +12,6 @@ import {
FolderKanban,
LogOut,
Plus,
Puzzle,
Library,
Store,
Settings as SettingsIcon,
UserCircle2,
@@ -604,21 +602,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<Store className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span></span> : null}
</button>
<button
type="button"
data-testid="sidebar-nav-my-plugins"
aria-current={location.pathname === '/my-plugins' ? 'page' : undefined}
aria-label="我的插件"
onClick={() => navigate('/my-plugins')}
className={cn(
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
location.pathname === '/my-plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
sidebarCollapsed && 'justify-center px-0',
)}
>
<Library className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span></span> : null}
</button>
</div>
<div className="mt-3">
<button
@@ -688,23 +671,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
</DisclosureContent>
) : null}
</div>
{activeProject ? (
<button
type="button"
data-testid="sidebar-nav-project-plugins"
aria-current={location.pathname === '/project-plugins' ? 'page' : undefined}
aria-label="项目插件"
onClick={() => navigate('/project-plugins')}
className={cn(
'motion-press mt-3 flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
location.pathname === '/project-plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
sidebarCollapsed && 'justify-center px-0',
)}
>
<Puzzle className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span></span> : null}
</button>
) : null}
</>
) : isLearningModule ? (
<LearningSidebar sidebarCollapsed={sidebarCollapsed} />

View File

@@ -37,6 +37,7 @@ function actionErrorText(error?: string | null): string | null {
}
export type MyPluginsViewProps = {
embedded?: boolean;
library: MarketplaceLibrarySnapshot | null;
installations: Record<string, MarketplaceInstallation>;
state: 'idle' | 'loading' | 'ready' | 'error';
@@ -53,9 +54,10 @@ export type MyPluginsViewProps = {
export function MyPluginsView(props: MyPluginsViewProps) {
const visibleError = actionErrorText(props.error);
const Root = props.embedded ? 'section' : 'main';
return (
<main data-testid="my-plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4"><div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]"></h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground"></p></div><Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}><RefreshCw className="mr-2 h-4 w-4" /></Button></header>
<Root data-testid="my-plugins-page" className="w-full text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4"><div>{props.embedded ? <h2 className="text-balance text-xl font-semibold tracking-[-0.02em]"></h2> : <h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]"></h1>}<p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground"></p></div><Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}><RefreshCw className="mr-2 h-4 w-4" /></Button></header>
{props.library?.stale ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">线</p> : null}
{props.state === 'loading' && !props.library ? <p role="status" className="mt-8 text-sm"></p> : null}
{props.state === 'error' && !props.library ? <div role="alert" className="mt-8 rounded-2xl bg-warning/10 p-5"><h2 className="font-semibold"></h2><p className="mt-2 text-pretty text-sm">{props.error ?? '请稍后重试。'}</p></div> : null}
@@ -87,17 +89,17 @@ export function MyPluginsView(props: MyPluginsViewProps) {
{!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy || suspended} aria-label={`安装 Beta${plugin.title}`} onClick={() => void props.onInstallBeta(plugin.pluginId)}> Beta</Button> : null}
{!removed && plugin.acquisition !== 'system_included' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy} aria-label={`移除${plugin.title}`} onClick={() => void props.onRemove(plugin.pluginId)}><Trash2 className="mr-2 h-4 w-4" /></Button> : null}
{hasDevicePackage ? <Button type="button" variant="ghost" className="min-h-10" disabled={busy} aria-label={`删除设备上的${plugin.title}`} onClick={() => void props.onUninstall(plugin.pluginId)}></Button> : null}
<Button asChild variant="outline" className="min-h-10"><Link to="/project-plugins"></Link></Button>
<Button asChild variant="outline" className="min-h-10"><Link to="/project-config?resource=plugins"></Link></Button>
<Button asChild variant="outline" className="min-h-10"><Link to="/project-config"></Link></Button>
</div>
</article>;
})}
</section>
</main>
</Root>
);
}
export function MyPlugins() {
export function MyPlugins({ embedded = false }: { embedded?: boolean } = {}) {
const user = useAuthStore((state) => state.user);
const accountKey = user?.userId ?? user?.username ?? null;
const library = usePluginMarketplaceStore((state) => state.library);
@@ -113,5 +115,5 @@ export function MyPlugins() {
toast.error(reason instanceof Error ? reason.message : String(reason));
});
const store = pluginMarketplaceStore.getState;
return <MyPluginsView library={library} installations={installations} state={state} error={error} pending={pending} onRefresh={() => safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />;
return <MyPluginsView embedded={embedded} library={library} installations={installations} state={state} error={error} pending={pending} onRefresh={() => safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />;
}

View File

@@ -1,8 +1,11 @@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MyPlugins } from '@/pages/MyPlugins';
import type {
MarketplaceCatalogPage,
MarketplaceCatalogQuery,
@@ -20,6 +23,7 @@ const USAGE_COPY: Record<MarketplaceUsageBilling, string> = {
};
export type PluginMarketplaceViewProps = {
embedded?: boolean;
catalog: MarketplaceCatalogPage | null;
detail: MarketplacePluginDetail | null;
library: MarketplaceLibrarySnapshot | null;
@@ -42,12 +46,15 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
event.preventDefault();
void props.onSearch({ ...(query.trim() ? { query: query.trim() } : {}), ...(category ? { category } : {}), limit: 24 });
};
const Root = props.embedded ? 'section' : 'main';
return (
<main data-testid="plugin-marketplace-page" className="mx-auto w-full max-w-7xl text-foreground">
<Root data-testid={props.embedded ? 'plugin-marketplace-discover' : 'plugin-marketplace-page'} className="w-full text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]"></h1>
{props.embedded
? <h2 className="text-balance text-xl font-semibold tracking-[-0.02em]"></h2>
: <h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]"></h1>}
<p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground"> MakeLore </p>
</div>
{props.catalog ? <p className="tabular-nums rounded-xl bg-surface-subtle px-3 py-2 text-sm" aria-label={`${props.catalog.total} 个插件`}><span className="tabular-nums">{props.catalog.total}</span> </p> : null}
@@ -103,11 +110,13 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
</section>
{props.detail ? <aside aria-label={`${props.detail.title}详情`} className="mt-6 rounded-2xl bg-surface-subtle p-5 sm:p-6"><div className="flex flex-wrap items-start justify-between gap-3"><div><p className="text-sm text-muted-foreground"></p><h2 className="mt-1 text-balance text-2xl font-semibold">{props.detail.title}</h2></div><Badge variant="outline"><ShieldCheck className="mr-1 h-3.5 w-3.5" />{props.detail.providerDisplayName}</Badge></div><p className="mt-3 whitespace-pre-wrap text-pretty text-sm">{props.detail.descriptionMarkdown}</p><h3 className="mt-5 text-balance font-semibold"></h3>{props.detail.permissions.length ? <ul className="mt-2 list-inside list-disc space-y-1 text-sm">{props.detail.permissions.map((permission) => <li key={permission} className="text-pretty">{permission}</li>)}</ul> : <p className="mt-2 text-sm text-muted-foreground"></p>}</aside> : null}
</main>
</Root>
);
}
export function PluginMarketplace() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get('tab') === 'mine' ? 'mine' : 'discover';
const user = useAuthStore((state) => state.user);
const accountKey = user?.userId ?? user?.username ?? null;
const catalog = usePluginMarketplaceStore((state) => state.catalog);
@@ -119,11 +128,34 @@ export function PluginMarketplace() {
const [selectedId, setSelectedId] = useState<string | null>(null);
useEffect(() => {
pluginMarketplaceStore.getState().activateAccount(accountKey);
void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined);
if (activeTab === 'discover') void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined);
if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined);
}, [accountKey]);
}, [accountKey, activeTab]);
const safe = (operation: Promise<void>) => operation.catch((error) => {
toast.error(error instanceof Error ? error.message : String(error));
});
return <PluginMarketplaceView catalog={catalog} detail={selectedId ? details[selectedId] ?? null : null} library={library} catalogState={catalogState} catalogError={catalogError} pending={pending} onSearch={(input) => safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />;
const handleTabChange = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === 'mine') next.set('tab', 'mine');
else next.delete('tab');
setSearchParams(next, { replace: true });
};
return <main data-testid="plugin-marketplace-page" className="mx-auto w-full max-w-7xl text-foreground">
<header>
<h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]"></h1>
<p className="mt-2 max-w-3xl text-pretty text-sm text-muted-foreground"></p>
</header>
<Tabs value={activeTab} onValueChange={handleTabChange} className="mt-6">
<TabsList aria-label="插件中心内容" className="grid w-full max-w-sm grid-cols-2">
<TabsTrigger value="discover"></TabsTrigger>
<TabsTrigger value="mine"></TabsTrigger>
</TabsList>
<TabsContent value="discover" className="mt-6">
<PluginMarketplaceView embedded catalog={catalog} detail={selectedId ? details[selectedId] ?? null : null} library={library} catalogState={catalogState} catalogError={catalogError} pending={pending} onSearch={(input) => safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />
</TabsContent>
<TabsContent value="mine" className="mt-6">
<MyPlugins embedded />
</TabsContent>
</Tabs>
</main>;
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react';
import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Puzzle, Trash2, Upload, X } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet';
import { AgentCreationDialog, type AgentCreationInput } from '@/components/coding/AgentCreationDialog';
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { ProjectPlugins } from '@/pages/ProjectPlugins';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { abortCodingConversation } from '@/lib/coding-conversations';
@@ -16,6 +17,7 @@ import { getSkillDisplayInfo } from '@/lib/skill-display';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { cn } from '@/lib/utils';
import { codingConversationStore } from '@/stores/coding-conversations';
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers';
@@ -23,7 +25,7 @@ import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-pro
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
const EMPTY_FILES: string[] = [];
type DrawerMode = 'models' | 'knowledge' | 'skills' | null;
type DrawerMode = 'models' | 'knowledge' | 'skills' | 'plugins' | null;
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
type SkillInfo = {
@@ -141,6 +143,8 @@ export function ProjectConfiguration() {
const providerAccounts = useProviderStore((state) => state.accounts);
const providerVendors = useProviderStore((state) => state.vendors);
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
const pluginProjection = useCodingPluginsStore((state) => state.projection);
const pluginLoadState = useCodingPluginsStore((state) => state.loadState);
const [draft, setDraft] = useState<CodingProjectConfig | null>(null);
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
@@ -185,17 +189,29 @@ export function ProjectConfiguration() {
setActiveAgentId(requestedAgentId);
setPartnerDialogOpen(true);
}, [draft, location.search]);
useEffect(() => {
if (new URLSearchParams(location.search).get('resource') === 'plugins') setDrawerMode('plugins');
}, [location.search]);
useEffect(() => { loadSkills(); }, [loadSkills]);
useEffect(() => {
if (drawerMode === 'skills') loadSkills();
}, [drawerMode, loadSkills]);
useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]);
useEffect(() => {
if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined);
}, [activeProject]);
const modelOptions = useMemo(
() => buildCodingModelOptions(providerAccounts, providerVendors),
[providerAccounts, providerVendors],
);
const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null;
const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null;
const currentPluginProjection = pluginProjection?.project.localProjectId === activeProject?.id ? pluginProjection : null;
const availablePluginCount = currentPluginProjection?.items.length ?? 0;
const enabledPluginCount = currentPluginProjection?.items.filter(({ enabled }) => enabled).length ?? 0;
const pluginSummary = !currentPluginProjection
? pluginLoadState === 'error' ? '项目插件暂不可用' : '正在读取项目插件'
: `${enabledPluginCount} · ${availablePluginCount} `;
const playSaveConfirmation = () => undefined;
const handleBack = () => navigate(-1);
@@ -455,8 +471,8 @@ export function ProjectConfiguration() {
</form>
)}
</section>
<section className="grid gap-3 md:grid-cols-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')} /></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>
<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={pluginSummary} onClick={() => setDrawerMode('plugins')} /></section>
<section id="project-agents-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>
<div data-testid="project-configuration-primary-actions" className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-start sm:justify-end">
@@ -486,11 +502,12 @@ export function ProjectConfiguration() {
title={activeAgent ? `${activeAgent.name || '未命名智能体'} · ` : '创建项目智能体'}
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">
<Sheet open={drawerMode !== null} onOpenChange={(open) => { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}><SheetContent className={cn('glass-surface flex flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none', drawerMode === 'plugins' ? 'w-[min(920px,96vw)]' : 'w-[min(430px,94vw)]')}>
{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}
{drawerMode === 'plugins' ? <DrawerFrame title="项目插件" desc="启用当前项目所需能力;插件提供的 Skill 仍在智能体配置中单独分配。" onClose={() => setDrawerMode(null)}><ProjectPlugins embedded onChanged={loadSkills} onResolveIdentity={() => setDrawerMode(null)} onManageAgents={() => setDrawerMode(null)} /></DrawerFrame> : null}
</SheetContent></Sheet>
<ConfirmDialog
open={Boolean(archiveAgentId)}

View File

@@ -42,6 +42,7 @@ function billingText(billing: PluginBilling): string {
}
type ViewProps = {
embedded?: boolean;
projectName: string;
projection: CodingPluginProject;
dataService: DataServiceInstanceState | null;
@@ -56,6 +57,8 @@ type ViewProps = {
onReset(): void | Promise<void>;
onRemoveCollection(collection: string): void | Promise<void>;
onRemoveProject(): void | Promise<void>;
onManageAgents?(): void;
onResolveIdentity?(): void;
};
export function ProjectPluginsView(props: ViewProps) {
@@ -69,16 +72,25 @@ export function ProjectPluginsView(props: ViewProps) {
const pendingToggle = selected ? Boolean(props.pending[`enabled:${props.projection.project.localProjectId}:${selected.id}`]) : false;
const libraryEntry = selected ? props.marketplaceLibrary?.items.find(({ pluginId }) => pluginId === selected.id) : null;
const installation = selected ? props.installations?.[selected.id] : null;
const Root = props.embedded ? 'section' : 'main';
const manageAgents = () => {
if (props.onManageAgents) props.onManageAgents();
else navigate('/project-config');
};
const resolveIdentity = () => {
if (props.onResolveIdentity) props.onResolveIdentity();
else navigate('/project-config');
};
return (
<main data-testid="project-plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4">
<div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]"></h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground"></p></div>
<Root data-testid="project-plugins-page" className={cn('w-full text-foreground', !props.embedded && 'mx-auto max-w-7xl')}>
<header className={cn('flex flex-wrap items-start justify-between gap-4', props.embedded && 'justify-end')}>
{!props.embedded ? <div><h1 className="text-balance text-3xl font-semibold tracking-[-0.03em]"></h1><p className="mt-2 max-w-2xl text-pretty text-sm text-muted-foreground"></p></div> : null}
<Button type="button" variant="outline" onClick={() => void props.onRefresh()} disabled={Boolean(props.pending[`load:${props.projection.project.localProjectId}`])}><RefreshCw className="mr-2 h-4 w-4" /></Button>
</header>
{props.projection.policyStatus === 'stale' ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm"></p> : null}
{props.refreshError ? <p role="status" className="mt-4 rounded-xl bg-warning/10 p-3 text-pretty text-sm">{props.refreshError}</p> : null}
<div className="mt-6 grid gap-5 lg:grid-cols-[minmax(240px,0.75fr)_minmax(0,1.75fr)]">
<div className="mt-6 grid gap-5 md:grid-cols-[minmax(220px,0.7fr)_minmax(0,1.8fr)]">
<section aria-label="可用插件" className="space-y-3">
{props.projection.items.map((plugin) => (
<button key={plugin.id} type="button" onClick={() => setSelectedId(plugin.id)} className={cn('motion-press flex min-h-10 w-full items-center gap-3 rounded-2xl bg-background p-4 text-left shadow-soft ring-1 ring-border/70 transition-[box-shadow,background-color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35', selected?.id === plugin.id && 'bg-brand-soft ring-brand/25')}>
@@ -100,12 +112,12 @@ export function ProjectPluginsView(props: ViewProps) {
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground"></p><p className="mt-1 font-semibold">v{selected.contractVersion}</p></div>
<div className="rounded-xl bg-surface-subtle p-3"><p className="text-xs text-muted-foreground"></p><p className="mt-1 font-semibold">{selected.requiresBackend ? '需要' : '不需要'}</p></div>
</section>
{selected.state === 'identity_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/project-config')}> ID</Button> : null}
{selected.state === 'authentication_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/login', { state: { from: '/project-plugins' } })}></Button> : null}
{selected.state === 'identity_required' ? <Button type="button" variant="outline" className="mt-5" onClick={resolveIdentity}> ID</Button> : null}
{selected.state === 'authentication_required' ? <Button type="button" variant="outline" className="mt-5" onClick={() => navigate('/login', { state: { from: '/project-config?resource=plugins' } })}></Button> : null}
<section className="mt-7" aria-labelledby="plugin-capabilities-heading"><h3 id="plugin-capabilities-heading" className="text-balance text-lg font-semibold"></h3>
<div className="mt-3 space-y-4">{selected.capabilities.map((capability) => <div key={capability.id} className="rounded-xl border border-border/70 p-4"><h4 className="font-mono text-sm font-semibold">{capability.id}</h4><div className="mt-3 space-y-3">{capability.operations.map((operation) => <div key={operation.id} className="rounded-lg bg-surface-subtle p-3"><div className="flex flex-wrap items-center gap-2"><code className="font-mono text-sm font-semibold">{operation.id}</code>{operation.tool ? <><Badge variant="outline">{MUTATION_TEXT[operation.tool.mutation]}</Badge><code className="text-xs text-muted-foreground">{operation.tool.name}</code></> : <Badge variant="outline">SDK</Badge>}</div>{operation.tool ? <><p className="mt-2 text-pretty text-sm text-muted-foreground">{operation.tool.description}</p><p className="mt-2 flex items-center gap-2 text-xs"><ShieldCheck className="h-4 w-4 text-brand" />{operation.tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}</p></> : <p className="mt-2 text-pretty text-sm text-muted-foreground"> Data Service SDK 使 Pi </p>}<p className="mt-2 text-pretty text-xs font-medium">{billingText(operation.billing)}</p></div>)}</div></div>)}</div>
</section>
<section className="mt-7" aria-labelledby="plugin-agents-heading"><div className="flex flex-wrap items-center justify-between gap-3"><h3 id="plugin-agents-heading" className="text-balance text-lg font-semibold"></h3><Button type="button" variant="outline" className="min-h-10" onClick={() => navigate('/project-config')}></Button></div>
<section className="mt-7" aria-labelledby="plugin-agents-heading"><div className="flex flex-wrap items-center justify-between gap-3"><h3 id="plugin-agents-heading" className="text-balance text-lg font-semibold"></h3><Button type="button" variant="outline" className="min-h-10" onClick={manageAgents}></Button></div>
<div className="mt-3 flex flex-wrap gap-2">{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => <Badge key={id} variant="secondary">{props.agentNames[id] ?? '未知伙伴'}</Badge>) : <p className="text-pretty text-sm text-muted-foreground"></p>}</div>
</section>
<section className="mt-7" aria-labelledby="plugin-billing-heading"><h3 id="plugin-billing-heading" className="text-balance text-lg font-semibold"></h3><p className="mt-2 text-pretty text-sm"></p></section>
@@ -115,11 +127,18 @@ export function ProjectPluginsView(props: ViewProps) {
</div>
{props.projection.unknownPluginIds.length ? <section aria-labelledby="retained-plugin-ids-heading" className="mt-6 rounded-2xl bg-warning/10 p-5"><h2 id="retained-plugin-ids-heading" className="text-balance text-lg font-semibold"></h2><p className="mt-2 text-pretty text-sm text-muted-foreground"> ID </p><ul className="mt-3 space-y-2">{props.projection.unknownPluginIds.map((pluginId) => <li key={pluginId} className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-background p-3 ring-1 ring-border/70"><code className="break-all text-sm">{pluginId}</code><Button type="button" variant="outline" className="min-h-10" aria-label={`从项目禁用保留插件 ${pluginId}`} onClick={() => void props.onSetEnabled(pluginId, false)}></Button></li>)}</ul></section> : null}
<Dialog open={Boolean(disableCandidate)} onOpenChange={(open) => { if (!open) setDisableCandidate(null); }}><DialogContent><DialogHeader><DialogTitle className="text-balance">{disableCandidate?.displayName}</DialogTitle><DialogDescription className="text-pretty">访</DialogDescription></DialogHeader><DialogFooter><Button type="button" variant="outline" onClick={() => setDisableCandidate(null)}></Button><Button type="button" onClick={() => { if (disableCandidate) void props.onSetEnabled(disableCandidate.id, false); setDisableCandidate(null); }}></Button></DialogFooter></DialogContent></Dialog>
</main>
</Root>
);
}
export function ProjectPlugins() {
export type ProjectPluginsProps = {
embedded?: boolean;
onManageAgents?(): void;
onResolveIdentity?(): void;
onChanged?(): void;
};
export function ProjectPlugins({ embedded = false, onManageAgents, onResolveIdentity, onChanged }: ProjectPluginsProps = {}) {
const user = useAuthStore((state) => state.user);
const accountKey = user?.userId ?? user?.username ?? null;
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
@@ -144,5 +163,13 @@ export function ProjectPlugins() {
const safe = (promise: Promise<void>) => promise.catch((reason) => {
toast.error(reason instanceof Error ? reason.message : String(reason));
});
return <ProjectPluginsView projectName={activeProject.name} projection={currentProjection} dataService={dataService} pending={pending} agentNames={agentNames} refreshError={loadState === 'error' ? error : null} marketplaceLibrary={marketplaceLibrary} installations={installations} onRefresh={() => safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={(pluginId, enabled) => safe(codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled))} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} />;
const setEnabled = async (pluginId: string, enabled: boolean) => {
try {
await codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled);
onChanged?.();
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : String(reason));
}
};
return <ProjectPluginsView embedded={embedded} projectName={activeProject.name} projection={currentProjection} dataService={dataService} pending={pending} agentNames={agentNames} refreshError={loadState === 'error' ? error : null} marketplaceLibrary={marketplaceLibrary} installations={installations} onRefresh={() => safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={setEnabled} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} onManageAgents={onManageAgents} onResolveIdentity={onResolveIdentity} />;
}