feat: 设置「插件」页落地——外置插件按来源分组(商店/项目/全局)展示与启停

This commit is contained in:
Rick Ye
2026-09-11 01:03:28 +08:00
parent 8b94d42930
commit 71c91563e8
3 changed files with 98 additions and 16 deletions
+11 -5
View File
@@ -37,6 +37,8 @@ export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTER
interface ResolvedPlugin {
manifest: PluginManifest
source: 'builtin' | 'external'
/** 外置来源目录类别(管理页分组展示用) */
origin?: 'user' | 'project' | 'global'
/** 外置插件根目录(plugin:// 映射根) */
dir?: string
/** 内置:第一方工厂 / 渲染层组件 key */
@@ -145,10 +147,12 @@ class PluginHost {
}
if (EXTERNAL_PLUGINS_ENABLED) {
const roots = [this.externalRoot]
if (this.projectExtensionsDir) roots.push(this.projectExtensionsDir)
roots.push(join(getAgentDir(), 'extensions'))
for (const root of roots) {
const roots: Array<{ root: string; origin: 'user' | 'project' | 'global' }> = [
{ root: this.externalRoot, origin: 'user' }
]
if (this.projectExtensionsDir) roots.push({ root: this.projectExtensionsDir, origin: 'project' })
roots.push({ root: join(getAgentDir(), 'extensions'), origin: 'global' })
for (const { root, origin } of roots) {
if (!existsSync(root)) continue
let entries: import('node:fs').Dirent[]
try {
@@ -177,11 +181,12 @@ class PluginHost {
manifest,
source: 'external',
dir,
origin,
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
})
continue
}
byId.set(manifest.id, { manifest, source: 'external', dir })
byId.set(manifest.id, { manifest, source: 'external', dir, origin })
}
}
}
@@ -275,6 +280,7 @@ class PluginHost {
.map((p) => ({
manifest: p.manifest,
source: p.source,
origin: p.origin,
enabled: this.isEnabled(p.manifest.id),
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
@@ -53,16 +53,12 @@ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: I
}
]
const PLACEHOLDER: Record<string, string> = {
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。'
}
const TAB_META: Record<Tab, { title: string; desc: string }> = {
general: { title: '常规', desc: '主题色等外观偏好。' },
corePlugins: { title: '核心插件', desc: '内置的第一方功能插件,官方维护' },
corePlugins: { title: '核心插件', desc: '内置的第一方功能插件,官方维护' },
model: { title: '模型设置', desc: '' },
prompt: { title: '系统提示词', desc: '' },
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
prompt: { title: '系统提示词', desc: '' },
plugins: { title: '插件', desc: '第三方扩展插件(商店 / 项目 / 全局目录),按需启停。' },
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' }
}
@@ -685,6 +681,80 @@ function CorePluginPanel({
)
}
/** 第三方插件页:外置插件按来源分组(商店 / 项目 .pi/extensions / 全局扩展目录),启停机制与核心插件相同 */
function ExternalPluginPanel({
togglingId,
onToggle,
devBuild
}: {
togglingId: string | null
onToggle: (plugin: PluginInfo, next: boolean) => void
/** 开发版构建(第三方装载开关开启);用户版展示关闭提示 */
devBuild: boolean
}): ReactNode {
const external = usePluginList().filter((p) => p.source === 'external')
const groups = [
{ label: '商店插件', hint: 'userData/plugins', items: external.filter((p) => p.origin === 'user') },
{ label: '项目插件', hint: '.pi/extensions(当前工作区)', items: external.filter((p) => p.origin === 'project') },
{ label: '全局插件', hint: '~/.pi/agent/extensions', items: external.filter((p) => p.origin === 'global') }
].filter((g) => g.items.length > 0)
if (groups.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
<div className="text-[13px] font-medium text-ink-2">
{devBuild ? '暂无第三方插件' : '第三方插件装载已关闭(用户版构建)'}
</div>
{devBuild && (
<div className="text-[11.5px] text-ink-3">把含 manifest.json 的插件目录放入上方任一来源即可自动识别</div>
)}
</div>
)
}
return (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.label}>
<div className="flex items-baseline gap-1.5 pb-1.5">
<span className="text-[11.5px] font-medium text-ink-2">{group.label}</span>
<span className="font-mono text-[10.5px] text-ink-3">{group.hint}</span>
</div>
<div className="space-y-1.5">
{group.items
.slice()
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
.map((p) => (
<div key={p.manifest.id} className="rounded-card border border-line px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[12.5px] font-medium text-ink">{p.manifest.name}</span>
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">v{p.manifest.version}</span>
<span className="ml-auto text-[11px] text-ink-3">{p.enabled ? '已启用' : '已停用'}</span>
<button
type="button"
role="switch"
aria-checked={p.enabled}
aria-label={`启用${p.manifest.name}`}
disabled={togglingId !== null}
onClick={() => onToggle(p, !p.enabled)}
className={`relative h-6 w-10 shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
p.enabled ? 'bg-accent' : 'bg-line-strong'
}`}
>
<span
className={`absolute top-0.5 size-5 rounded-full bg-white shadow transition-[left] ${p.enabled ? 'left-[18px]' : 'left-0.5'}`}
/>
</button>
</div>
{p.manifest.description && <div className="mt-1 text-[12px] leading-5 text-ink-3">{p.manifest.description}</div>}
{p.error && <div className="mt-1 text-[12px] text-red">装载失败:{p.error}</div>}
</div>
))}
</div>
</div>
))}
</div>
)
}
/** 系统提示词预览:整段 pi 提示词是大文本,memo 化避免弹窗内任意状态变化触发重渲染;挂载耗时靠过渡提交兜住 */
const PromptPreview = memo(function PromptPreview({ loading, text }: { loading: boolean; text: string }): ReactNode {
return (
@@ -773,8 +843,8 @@ export function SettingsModal({
.finally(() => setBusy(false))
}
/** 切换核心插件启停:主进程持久化后重建运行时;列表经 plugins:listChanged 自动刷新 */
const toggleCorePlugin = (plugin: PluginInfo, next: boolean): void => {
/** 切换插件启停(内置/外置同机制):主进程持久化后重建运行时;列表经 plugins:listChanged 自动刷新 */
const togglePlugin = (plugin: PluginInfo, next: boolean): void => {
if (streaming) {
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再切换(切换会重建运行时)' })
return
@@ -984,7 +1054,11 @@ export function SettingsModal({
</div>
)}
{tab === 'corePlugins' && <CorePluginPanel togglingId={togglingId} onToggle={toggleCorePlugin} />}
{tab === 'corePlugins' && <CorePluginPanel togglingId={togglingId} onToggle={togglePlugin} />}
{tab === 'plugins' && (
<ExternalPluginPanel togglingId={togglingId} onToggle={togglePlugin} devBuild={import.meta.env.DEV} />
)}
{tab === 'prompt' && (
<div className="flex h-full min-h-0 flex-col">
@@ -1093,7 +1167,7 @@ export function SettingsModal({
/>
)}
{tab !== 'general' && tab !== 'corePlugins' && tab !== 'model' && tab !== 'prompt' && tab !== 'skills' && tab !== 'commands' && (
{tab !== 'general' && tab !== 'corePlugins' && tab !== 'plugins' && tab !== 'model' && tab !== 'prompt' && tab !== 'skills' && tab !== 'commands' && (
<ComingSoon icon={NAV[1].items.find((i) => i.id === tab)?.icon ?? LuPuzzle} label={meta.title} />
)}
</div>
+2
View File
@@ -367,6 +367,8 @@ import type { PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-s
export interface PluginInfo {
manifest: PluginManifest
source: 'builtin' | 'external'
/** 外置来源目录类别(仅 external):user=商店目录 / project=项目 .pi/extensions / global=全局扩展目录 */
origin?: 'user' | 'project' | 'global'
enabled: boolean
/** 有主进程工厂(pi 工具/事件/内联 UI 注册能力) */
hasMain: boolean