feat: move plugin workspace into project settings

This commit is contained in:
inman
2026-09-07 10:06:37 +08:00
parent f8eee430f4
commit be1764e016
19 changed files with 363 additions and 76 deletions

View File

@@ -26,14 +26,17 @@ import { subscribeHostEvent } from '@/lib/host-events';
import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api';
import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics';
import { resolveSupportedLanguage } from '../shared/language';
import { LegacyPluginRedirect } from './pages/Plugins/legacy-plugin-redirect';
import {
LegacyPluginRedirect,
PluginWorkspaceRedirect,
PROJECT_SETTINGS_PLUGINS_PATH,
} from './pages/Plugins/legacy-plugin-redirect';
const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component })));
const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ default: 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 Plugins = lazy(() => import('./pages/Plugins').then(({ Plugins: 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 })));
@@ -442,7 +445,8 @@ function App() {
)}
>
<Route path="/project-config" element={<ProjectConfiguration />} />
<Route path="/plugins" element={<Plugins />} />
<Route path={PROJECT_SETTINGS_PLUGINS_PATH} element={<ProjectConfiguration />} />
<Route path="/plugins" element={<PluginWorkspaceRedirect />} />
<Route path="/plugin-marketplace" element={<LegacyPluginRedirect from="/plugin-marketplace" />} />
<Route path="/my-plugins" element={<LegacyPluginRedirect from="/my-plugins" />} />
<Route path="/project-plugins" element={<LegacyPluginRedirect from="/project-plugins" />} />

View File

@@ -38,6 +38,7 @@ export function MainLayout() {
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isChatWorkspace = location.pathname === '/chat';
const isInitializationSafeRoute = location.pathname === '/project-config'
|| location.pathname.startsWith('/project-config/')
|| location.pathname === '/plugins'
|| !isProgrammingModule;

View File

@@ -12,7 +12,6 @@ import {
FolderKanban,
LogOut,
Plus,
Puzzle,
Settings as SettingsIcon,
UserCircle2,
} from 'lucide-react';
@@ -584,23 +583,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '新建项目'}
</button>
<div className="mt-3 grid gap-1">
<button
type="button"
data-testid="sidebar-nav-plugins"
aria-current={location.pathname === '/plugins' ? 'page' : undefined}
aria-label="插件"
onClick={() => navigate(activeProject ? '/plugins?scope=project' : '/plugins?scope=all')}
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 === '/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>
</div>
<div className="mt-3">
<button
type="button"

View File

@@ -90,6 +90,7 @@ export function isAiModuleAllowed(moduleId: AiModuleId, access: ModuleAccess): b
}
export function isProgrammingProviderRoute(pathname: string): boolean {
if (matchesRoute(pathname, '/project-config/plugins')) return false;
return PROGRAMMING_PROVIDER_ROUTE_PREFIXES.some((route) => matchesRoute(pathname, route));
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, type FormEvent } from 'react';
import { AlertCircle, Puzzle, RefreshCw, Search } from 'lucide-react';
import { AlertCircle, ArrowLeft, Puzzle, RefreshCw, Search } 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';
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
@@ -23,6 +24,7 @@ import type {
} 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 提供',
@@ -151,6 +153,8 @@ export interface PluginsViewProps {
onResetDataService(): void | Promise<void>;
onRemoveDataServiceCollection(collection: string): void | Promise<void>;
onRemoveDataServiceProject(): void | Promise<void>;
embedded?: boolean;
onBackToProjectSettings?(): void;
}
export function PluginsView(props: PluginsViewProps) {
@@ -176,21 +180,46 @@ export function PluginsView(props: PluginsViewProps) {
};
return (
<main data-testid="plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-brand">MakeLore Code</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>
<main
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">
<div className="flex min-w-0 items-start gap-3">
{props.onBackToProjectSettings ? (
<Button
type="button"
variant="ghost"
size="icon"
data-testid="project-plugins-back-button"
aria-label="返回项目配置"
title="返回项目配置"
onClick={props.onBackToProjectSettings}
className="mt-0.5 h-8 w-8 shrink-0 text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<div className="min-w-0">
<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>
</header>}
<section aria-label="插件筛选" className="mt-6 rounded-2xl border border-border/70 bg-surface-subtle p-4">
<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">
@@ -351,7 +380,7 @@ function scopeFallbackSearchFromLocationState(state: unknown): string | null {
return typeof value === 'string' ? value : null;
}
export function Plugins() {
export function Plugins({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -613,7 +642,7 @@ export function Plugins() {
marketplace: pluginMarketplaceStore.getState(),
device: devicePackageStore.getState(),
project: codingPluginsStore.getState(),
loginReturnPath: `/plugins?${serializePluginWorkspaceSearch(filters).toString()}`,
loginReturnPath: `${PROJECT_SETTINGS_PLUGINS_PATH}?${serializePluginWorkspaceSearch(filters).toString()}`,
navigate: (path, options) => navigate(path, options),
openSettings: () => document.getElementById('plugin-settings')?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
}));
@@ -641,6 +670,8 @@ export function Plugins() {
onResetDataService={() => safe(codingPluginsStore.getState().reset())}
onRemoveDataServiceCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))}
onRemoveDataServiceProject={() => safe(codingPluginsStore.getState().removeProject())}
embedded={embedded}
onBackToProjectSettings={embedded ? undefined : () => navigate('/project-config')}
/>
);
}

View File

@@ -1,13 +1,26 @@
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
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': '/plugins?scope=all&source=official',
'/my-plugins': '/plugins?scope=all&state=mine',
'/project-plugins': '/plugins?scope=project',
'/plugin-marketplace': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=official`,
'/my-plugins': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`,
'/project-plugins': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project`,
};
export function LegacyPluginRedirect({ from }: { from: LegacyPluginPath }) {
return <Navigate to={LEGACY_PLUGIN_REDIRECTS[from]} replace />;
}
export function PluginWorkspaceRedirect() {
const location = useLocation();
return (
<Navigate
to={{ pathname: PROJECT_SETTINGS_PLUGINS_PATH, search: location.search }}
state={location.state}
replace
/>
);
}

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,8 @@ 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 { Plugins } from '@/pages/Plugins';
import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { abortCodingConversation } from '@/lib/coding-conversations';
@@ -70,6 +72,23 @@ function DrawerFrame({ title, desc, onClose, children, closeLabel = '关闭', cl
return <div className="flex min-h-0 flex-1 flex-col"><div className="glass-surface drawer-motion-header flex items-center justify-between gap-3 border-b border-border/70 bg-background px-5 py-4"><div className="min-w-0 flex-1"><SheetTitle className="text-lg font-semibold">{title}</SheetTitle><SheetDescription className="mt-1 text-sm font-medium text-muted-foreground">{desc}</SheetDescription></div>{closeIcon === 'back' ? closeButton : <SheetClose asChild>{closeButton}</SheetClose>}</div><div className="drawer-motion-body min-h-0 flex-1 overflow-auto p-5">{children}</div></div>;
}
function PluginWorkspaceSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
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"
>
<DrawerFrame
title="插件"
desc="在当前项目页查看插件、启用项目能力并管理伙伴分配;获取、设备安装和项目启用仍彼此独立,本机 Skill 和 Pi extension 仍仅通过对话安装。"
onClose={onClose}
>
<Plugins embedded />
</DrawerFrame>
</SheetContent>
</Sheet>;
}
function SkillDetail({ skill }: { skill: SkillInfo }) {
return <div data-testid="skill-detail-view" className="space-y-4">
<section data-testid="skill-structure" className="rounded-xl border border-foreground/15 bg-surface-subtle p-4">
@@ -198,8 +217,14 @@ export function ProjectConfiguration() {
const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null;
const playSaveConfirmation = () => undefined;
const handleBack = () => navigate(-1);
const pluginWorkspaceOpen = location.pathname === PROJECT_SETTINGS_PLUGINS_PATH;
const openPluginWorkspace = () => navigate({
pathname: PROJECT_SETTINGS_PLUGINS_PATH,
search: `?scope=${activeProject ? 'project' : 'all'}`,
});
const closePluginWorkspace = () => navigate('/project-config', { replace: true });
if (!activeProject) return <main data-testid="project-configuration-empty-state" className="flex min-h-full items-start bg-background p-8 text-foreground"><p className="pt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p></main>;
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置智能体、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看账号与本机插件" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
@@ -455,7 +480,7 @@ 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="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 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>
@@ -492,6 +517,7 @@ export function ProjectConfiguration() {
{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}
</SheetContent></Sheet>
<PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} />
<ConfirmDialog
open={Boolean(archiveAgentId)}
title={`${draft.agents.find((agent) => agent.id === archiveAgentId)?.name || '未命名智能体'}`}