feat: 插件声明式设置体系——registerSettings schema + 标准控件表单 + 一级导航入口开关

This commit is contained in:
Rick Ye
2026-09-11 01:45:32 +08:00
parent 8349db9947
commit 2ad1743e10
10 changed files with 887 additions and 13 deletions
+43 -1
View File
@@ -1,5 +1,5 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync } from 'node:fs'
import { mkdirSync, readFileSync } from 'node:fs'
import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service'
import { getPluginHost } from './plugins/plugin-host'
@@ -404,6 +404,48 @@ export function registerIpc(): AgentService {
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
return getPluginHost().readRendererSource(id)
})
// ---- 插件设置表单(声明式 schema;值持久化与动作回推在 plugin-host) ----
ipcMain.handle('plugins:settingsSchemas', () => getPluginHost().settingsSchemasList())
ipcMain.handle('plugins:settingsValues', () => getPluginHost().settingsValuesList())
ipcMain.handle('plugins:setSettingValue', (_e, id: unknown, key: unknown, value: unknown) => {
if (typeof id !== 'string' || typeof key !== 'string' || !key) throw new Error('invalid setting value')
getPluginHost().setSettingValue(id, key, value)
})
ipcMain.handle('plugins:setSettingsNav', (_e, id: unknown, on: unknown) => {
if (typeof id !== 'string' || typeof on !== 'boolean') throw new Error('invalid settings nav toggle')
return getPluginHost().setSettingsNav(id, on)
})
ipcMain.handle('plugins:settingsAction', (_e, id: unknown, key: unknown) => {
if (typeof id !== 'string' || typeof key !== 'string') throw new Error('invalid settings action')
getPluginHost().settingsAction(id, key)
})
// path/image 控件的系统选择框
ipcMain.handle('plugins:pickPath', async (event, mode: unknown) => {
if (mode !== 'file' && mode !== 'directory' && mode !== 'image') throw new Error('invalid path mode')
const win = BrowserWindow.fromWebContents(event.sender)
const filters =
mode === 'image'
? [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif'] }]
: undefined
const options = { filters }
const result =
mode === 'directory'
? await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openDirectory'] }) : dialog.showOpenDialog({ ...options, properties: ['openDirectory'] }))
: await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openFile'] }) : dialog.showOpenDialog({ ...options, properties: ['openFile'] }))
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0]
})
// image 控件缩略图:渲染层(dev 是 http 源)不能直接 <img src="file://">,回传 data URL
ipcMain.handle('plugins:readImage', (_e, path: unknown) => {
if (typeof path !== 'string' || !path || !/\.(png|jpe?g|gif|webp|bmp|svg|ico|avif)$/i.test(path)) return null
try {
const buf = readFileSync(path)
if (buf.length > 5 * 1024 * 1024) return null
const ext = path.split('.').pop()!.toLowerCase()
return `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`
} catch {
return null
}
})
ipcMain.handle('plugins:setEnabled', (_e, id: unknown, enabled: unknown) => {
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
return getPluginHost().setEnabled(id, enabled)
+192 -7
View File
@@ -24,12 +24,21 @@ import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getAgentDir } from '@earendil-works/pi-coding-agent'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
import type {
HarnessPluginFactory,
PluginManifest,
PluginSettingsSchema,
PluginSettingValues,
PluginSettingsSchemas,
PluginSettingsValuesMap,
StatusItem,
ToolbarButtonDef
} from '@aso/plugin-sdk'
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
export const HARNESS_CONTRACT_VERSION = '0.1.0'
export const HARNESS_CONTRACT_VERSION = '0.2.0'
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
@@ -72,6 +81,14 @@ class PluginHost {
private disabled = new Set<string>()
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
private projectExtensionsDir: string | null = null
/** 插件设置:声明 schema(激活时重新声明)/ 用户改过的值 / 一级导航开关 */
private settingsSchemas = new Map<string, PluginSettingsSchema>()
private settingsValues = new Map<string, PluginSettingValues>()
private settingsNavSet = new Set<string>()
/** 设置页动作/值变更的激活期回调(每次插件激活时重置) */
private settingsActions = new Map<string, Set<(key: string) => void>>()
private settingsListeners = new Map<string, Set<(values: PluginSettingValues) => void>>()
private valuesSaveTimer: NodeJS.Timeout | null = null
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
private rebuildRuntime: (() => Promise<void>) | null = null
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
@@ -93,6 +110,10 @@ class PluginHost {
return join(app.getPath('userData'), 'aso-plugin-storage.json')
}
private get valuesFile(): string {
return join(app.getPath('userData'), 'aso-plugin-values.json')
}
// ---- 生命周期 ----
init(): void {
@@ -102,11 +123,23 @@ class PluginHost {
// 已存在或创建失败都继续(扫描时再报)
}
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown }
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown; settingsNav?: unknown }
if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string'))
if (Array.isArray(raw.settingsNav))
this.settingsNavSet = new Set(raw.settingsNav.filter((d): d is string => typeof d === 'string'))
} catch {
// 无设置文件 = 全部启用
}
try {
const raw = JSON.parse(readFileSync(this.valuesFile, 'utf8')) as Record<string, unknown>
for (const [pid, kv] of Object.entries(raw)) {
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
this.settingsValues.set(pid, { ...(kv as PluginSettingValues) })
}
}
} catch {
// 无值文件 = 空
}
try {
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
for (const [pid, kv] of Object.entries(raw)) {
@@ -192,6 +225,14 @@ class PluginHost {
}
this.resolved = byId
// 清掉已卸载插件的设置 schema(值保留在盘上,重装后自动回来)
for (const id of [...this.settingsSchemas.keys()]) {
if (!byId.has(id)) {
this.settingsSchemas.delete(id)
this.dropSettingsCallbacks(id)
}
}
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
await this.loadExternalMains()
this.send('plugins:listChanged', null)
}
@@ -261,12 +302,108 @@ class PluginHost {
async setEnabled(id: string, enabled: boolean): Promise<void> {
if (enabled) this.disabled.delete(id)
else this.disabled.add(id)
this.persistPluginPrefs()
await this.reload()
}
/** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */
async setSettingsNav(id: string, on: boolean): Promise<void> {
const plugin = this.resolved.get(id)
if (!plugin || !this.settingsSchemas.has(id)) return
if (on) this.settingsNavSet.add(id)
else this.settingsNavSet.delete(id)
this.persistPluginPrefs()
this.send('plugins:listChanged', null)
}
private persistPluginPrefs(): void {
try {
writeFileSync(this.settingsFile, JSON.stringify({ disabled: [...this.disabled] }, null, 2), 'utf8')
writeFileSync(
this.settingsFile,
JSON.stringify({ disabled: [...this.disabled], settingsNav: [...this.settingsNavSet] }, null, 2),
'utf8'
)
} catch (err) {
console.error('[plugins] save settings failed:', err)
}
await this.reload()
}
// ---- 插件设置表单(声明式 schema + 值持久化 + 动作/变更回推) ----
/** 激活期回调容器重置(每次插件工厂执行前调用) */
private resetSettingsCallbacks(id: string): void {
this.settingsActions.delete(id)
this.settingsListeners.delete(id)
}
private dropSettingsCallbacks(id: string): void {
this.settingsActions.delete(id)
this.settingsListeners.delete(id)
}
private settingsSchemasSnapshot(): PluginSettingsSchemas {
return Object.fromEntries(this.settingsSchemas)
}
private settingsValuesSnapshot(): PluginSettingsValuesMap {
const out: PluginSettingsValuesMap = {}
for (const [pid, schema] of this.settingsSchemas) {
const stored = this.settingsValues.get(pid) ?? {}
// 默认值兜底:未设置过的键回落 schema default
const defaults: PluginSettingValues = {}
for (const item of schema.items) {
if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default
}
out[pid] = { ...defaults, ...stored }
}
return out
}
settingsSchemasList(): PluginSettingsSchemas {
return this.settingsSchemasSnapshot()
}
settingsValuesList(): PluginSettingsValuesMap {
return this.settingsValuesSnapshot()
}
setSettingValue(id: string, key: string, value: unknown): void {
if (!this.settingsSchemas.has(id)) return
const kv = { ...(this.settingsValues.get(id) ?? {}), [key]: value }
this.settingsValues.set(id, kv)
this.scheduleValuesSave()
this.send('plugins:settingsValues', this.settingsValuesSnapshot())
for (const fn of this.settingsListeners.get(id) ?? []) {
try {
fn({ ...(this.settingsValues.get(id) ?? {}) })
} catch (err) {
console.error('[plugins] settings listener error:', err)
}
}
}
settingsAction(id: string, key: string): void {
for (const fn of this.settingsActions.get(id) ?? []) {
try {
fn(key)
} catch (err) {
console.error('[plugins] settings action error:', err)
}
}
}
private scheduleValuesSave(): void {
if (this.valuesSaveTimer) return
this.valuesSaveTimer = setTimeout(() => {
this.valuesSaveTimer = null
const doc: Record<string, PluginSettingValues> = {}
for (const [pid, kv] of this.settingsValues) doc[pid] = kv
try {
writeFileSync(this.valuesFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
console.error('[plugins] save values failed:', err)
}
}, 300)
}
// ---- 查询 ----
@@ -286,6 +423,8 @@ class PluginHost {
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
hasComponent: Boolean(p.componentKey),
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
error: p.error
}))
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
@@ -365,11 +504,14 @@ class PluginHost {
this.send('plugins:uiState', this.uiState())
}
/** 清空某插件的全部内联 UI(禁用/重载路径) */
/** 清空某插件的全部内联 UI(禁用/重载路径);设置 schema 一并撤下(导航项随之消失) */
private clearPluginUi(pluginId: string): void {
this.statusItems.delete(pluginId)
this.toolbarButtons.delete(pluginId)
this.settingsSchemas.delete(pluginId)
this.dropSettingsCallbacks(pluginId)
this.send('plugins:uiState', this.uiState())
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
}
// ---- 深链 ----
@@ -429,6 +571,7 @@ class PluginHost {
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
const factory = plugin.builtinFactory ?? plugin.externalFactory
if (!factory) continue
this.resetSettingsCallbacks(plugin.manifest.id)
try {
const result = factory(this.buildApi(plugin, pi))
if (typeof result === 'function') {
@@ -460,6 +603,22 @@ class PluginHost {
private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) {
const id = plugin.manifest.id
const actions = (() => {
let set = this.settingsActions.get(id)
if (!set) {
set = new Set()
this.settingsActions.set(id, set)
}
return set
})()
const listeners = (() => {
let set = this.settingsListeners.get(id)
if (!set) {
set = new Set()
this.settingsListeners.set(id, set)
}
return set
})()
return {
pi,
manifest: plugin.manifest,
@@ -469,7 +628,33 @@ class PluginHost {
setStatusItem: (itemId: string, item: StatusItem) => this.setStatusItem(id, itemId, item),
removeStatusItem: (itemId: string) => this.setStatusItem(id, itemId, null),
registerToolbarButton: (button: ToolbarButtonDef) => this.registerToolbarButton(id, button),
removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId)
removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId),
registerSettings: (schema: PluginSettingsSchema) => {
if (!schema || !Array.isArray(schema.items)) return
if (schema.items.length === 0) this.settingsSchemas.delete(id)
else this.settingsSchemas.set(id, schema)
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
this.send('plugins:listChanged', null)
},
getSettingValues: (): PluginSettingValues => {
const schema = this.settingsSchemas.get(id)
const stored = this.settingsValues.get(id) ?? {}
const defaults: PluginSettingValues = {}
if (schema) {
for (const item of schema.items) {
if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default
}
}
return { ...defaults, ...stored }
},
onSettingsChange: (listener: (values: PluginSettingValues) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
onSettingsAction: (listener: (key: string) => void) => {
actions.add(listener)
return () => actions.delete(listener)
}
}
}
}
+26
View File
@@ -25,6 +25,7 @@ import type {
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
const api = {
// 窗口控制(自绘标题栏按钮)
@@ -142,6 +143,31 @@ const api = {
pluginsDirs: (): Promise<{ user: string[]; project: string[]; global: string[] }> =>
ipcRenderer.invoke('plugins:dirs'),
pluginsReadRenderer: (id: string): Promise<string> => ipcRenderer.invoke('plugins:readRenderer', id),
pluginsSettingsSchemas: (): Promise<PluginSettingsSchemas> => ipcRenderer.invoke('plugins:settingsSchemas'),
pluginsSettingsValues: (): Promise<PluginSettingsValuesMap> => ipcRenderer.invoke('plugins:settingsValues'),
pluginsSetSettingValue: (id: string, key: string, value: unknown): Promise<void> =>
ipcRenderer.invoke('plugins:setSettingValue', id, key, value),
pluginsSetSettingsNav: (id: string, on: boolean): Promise<void> =>
ipcRenderer.invoke('plugins:setSettingsNav', id, on),
pluginsSettingsAction: (id: string, key: string): Promise<void> =>
ipcRenderer.invoke('plugins:settingsAction', id, key),
pluginsPickPath: (mode: 'file' | 'directory' | 'image'): Promise<string | null> =>
ipcRenderer.invoke('plugins:pickPath', mode),
pluginsReadImage: (path: string): Promise<string | null> => ipcRenderer.invoke('plugins:readImage', path),
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, schemas: PluginSettingsSchemas): void => callback(schemas)
ipcRenderer.on('plugins:settingsSchemas', listener)
return () => {
ipcRenderer.removeListener('plugins:settingsSchemas', listener)
}
},
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, values: PluginSettingsValuesMap): void => callback(values)
ipcRenderer.on('plugins:settingsValues', listener)
return () => {
ipcRenderer.removeListener('plugins:settingsValues', listener)
}
},
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
ipcRenderer.invoke('plugins:setEnabled', id, enabled),
pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'),
@@ -27,10 +27,13 @@ import type {
SessionState,
ThemeMode
} from '@shared/types'
import type { PluginSettingsSchema, PluginSettingsSchemas, PluginSettingValues, PluginSettingsValuesMap } from '@aso/plugin-sdk'
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
import { usePluginList } from '../plugins/store'
import { PluginIcon } from '../plugins/PluginPageFrame'
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands'
type PluginSettingsTab = `plugin:${string}`
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [
@@ -52,7 +55,7 @@ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: I
}
]
const TAB_META: Record<Tab, { title: string; desc: string }> = {
const TAB_META: Record<Exclude<Tab, PluginSettingsTab>, { title: string; desc: string }> = {
general: { title: '常规', desc: '主题色等外观偏好。' },
model: { title: '模型设置', desc: '' },
prompt: { title: '系统提示词', desc: '' },
@@ -631,7 +634,9 @@ function PluginsPanel({
togglingId,
onToggle,
devBuild,
onOpenDir
onOpenDir,
onOpenSettings,
onNavToggle
}: {
/** 正在切换中的插件 id(切换期间全部开关禁用,避免并发重建运行时) */
togglingId: string | null
@@ -639,6 +644,10 @@ function PluginsPanel({
/** 开发版构建(第三方装载开关开启);用户版展示关闭提示 */
devBuild: boolean
onOpenDir: (path: string) => void
/** 打开插件自己的二级配置页(仅已声明设置表单的插件出现) */
onOpenSettings: (pluginId: string) => void
/** 把设置入口固定/移出到设置页一级导航 */
onNavToggle: (pluginId: string, on: boolean) => void
}): ReactNode {
const plugins = usePluginList()
const [scope, setScope] = useState<'user' | 'project' | 'builtin'>('user')
@@ -727,6 +736,41 @@ function PluginsPanel({
{scope === 'builtin' ? '内置' : scope === 'user' ? '全局' : '项目'} v{p.manifest.version}
</span>
<span className="ml-auto text-[11px] text-ink-3">{p.enabled ? '已启用' : '已停用'}</span>
{p.hasSettings && p.enabled && (
<>
<button
type="button"
title="打开配置页"
aria-label={`打开${p.manifest.name}配置页`}
onClick={() => onOpenSettings(p.manifest.id)}
className="shrink-0 rounded-control p-1 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
<LuSettings2 size={13} />
</button>
<span
className={`shrink-0 rounded-control px-1.5 py-0.5 text-[10px] transition-colors ${
p.settingsNav ? 'bg-accent/10 text-accent' : 'bg-hover text-ink-3'
}`}
>
一级入口
</span>
<button
type="button"
role="switch"
aria-checked={p.settingsNav}
aria-label={`一级入口${p.manifest.name}`}
title="把设置入口固定到左侧导航「Agent 能力」分组"
onClick={() => onNavToggle(p.manifest.id, !p.settingsNav)}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
p.settingsNav ? 'bg-accent' : 'bg-line-strong'
}`}
>
<span
className={`absolute top-0.5 size-4 rounded-full bg-white shadow transition-[left] ${p.settingsNav ? 'left-[18px]' : 'left-0.5'}`}
/>
</button>
</>
)}
<button
type="button"
role="switch"
@@ -753,6 +797,291 @@ function PluginsPanel({
)
}
// 插件设置导航图标(svg 字符串 → 组件;按 id+svg 缓存保证身份稳定,避免导航项重挂载)
const pluginSettingsIconCache = new Map<string, IconType>()
function pluginSettingsIcon(id: string, svg: string | undefined): IconType {
const key = `${id}:${svg ?? ''}`
const cached = pluginSettingsIconCache.get(key)
if (cached) return cached
const Icon: IconType = (props) => (
<PluginIcon svg={svg} size={typeof props.size === 'number' ? props.size : 16} className={props.className} />
)
pluginSettingsIconCache.set(key, Icon)
return Icon
}
/** 插件设置表单:按声明式 schema 渲染标准控件;值变化即时回写宿主(持久化 + 广播回推插件) */
function PluginSettingsForm({
schema,
values,
onChange,
onAction
}: {
schema: PluginSettingsSchema
values: PluginSettingValues
onChange: (key: string, value: unknown) => void
onAction: (key: string) => void
}): ReactNode {
const [picking, setPicking] = useState(false)
// image 控件缩略图(file:// 在 dev http 源不可直接作 src,经 IPC 换 data URL;按路径缓存)
const [imgUrls, setImgUrls] = useState<Record<string, string>>({})
const imgUrlCache = useRef<Record<string, string>>({})
useEffect(() => {
let alive = true
for (const item of schema.items) {
if (item.type !== 'image' || typeof values[item.key] !== 'string') continue
const path = values[item.key] as string
const cached = imgUrlCache.current[path]
if (cached) {
setImgUrls((prev) => ({ ...prev, [item.key]: cached }))
continue
}
window.api
.pluginsReadImage(path)
.then((url) => {
if (!alive || !url) return
imgUrlCache.current[path] = url
setImgUrls((prev) => ({ ...prev, [item.key]: url }))
})
.catch(() => undefined)
}
return () => {
alive = false
}
}, [schema, values])
const pickPath = async (mode: 'file' | 'directory' | 'image', key: string): Promise<void> => {
setPicking(true)
try {
const path = await window.api.pluginsPickPath(mode)
if (path) onChange(key, path)
} finally {
setPicking(false)
}
}
const inputCls =
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3'
return (
<div className="space-y-1">
{schema.items.map((item, idx) => {
const key = 'key' in item ? item.key : `__item${idx}`
const value = values[key]
const desc =
'description' in item && item.description ? (
<div className="mt-0.5 text-[11px] leading-4 text-ink-3">{item.description}</div>
) : null
if (item.type === 'divider') {
return item.label ? (
<div key={key} className="pt-3 text-[11.5px] font-medium text-ink-2">
{item.label}
</div>
) : (
<div key={key} className="my-2 border-t border-line" />
)
}
if (item.type === 'info') {
return (
<div key={key} className="rounded-card bg-inset px-3 py-2 text-[12px] leading-5 text-ink-2">
{item.label}
{desc}
</div>
)
}
if (item.type === 'button') {
return (
<div key={key} className="flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5">
<div className="min-w-0">
<div className="text-[12.5px] text-ink">{item.label}</div>
{desc}
</div>
<button
type="button"
onClick={() => onAction(item.key)}
className={
item.variant === 'primary'
? 'shrink-0 rounded-control bg-accent px-3 py-1.5 text-[11.5px] font-medium text-white hover:opacity-90'
: 'shrink-0 rounded-control border border-line-strong px-3 py-1.5 text-[11.5px] text-ink hover:bg-hover'
}
>
{item.label}
</button>
</div>
)
}
let control: ReactNode = null
switch (item.type) {
case 'string':
control = (
<input
type={item.password ? 'password' : 'text'}
value={typeof value === 'string' ? value : ''}
placeholder={item.placeholder}
onChange={(e) => onChange(item.key, e.target.value)}
className={`${inputCls} w-56 shrink-0`}
/>
)
break
case 'text':
return (
<div key={key} className="rounded-card border border-line px-3 py-2.5">
<div className="text-[12.5px] text-ink">{item.label}</div>
{desc}
<textarea
rows={item.rows ?? 3}
value={typeof value === 'string' ? value : ''}
placeholder={item.placeholder}
onChange={(e) => onChange(item.key, e.target.value)}
className={`${inputCls} mt-2 resize-y font-mono`}
/>
</div>
)
case 'number':
control = (
<input
type="number"
min={item.min}
max={item.max}
step={item.step}
value={typeof value === 'number' ? value : ''}
onChange={(e) => onChange(item.key, e.target.value === '' ? undefined : Number(e.target.value))}
className={`${inputCls} w-28 shrink-0`}
/>
)
break
case 'boolean':
control = (
<button
type="button"
role="switch"
aria-checked={value === true}
aria-label={item.label}
onClick={() => onChange(item.key, !(value === true))}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors ${
value === true ? 'bg-accent' : 'bg-line-strong'
}`}
>
<span
className={`absolute top-0.5 size-4 rounded-full bg-white shadow transition-[left] ${value === true ? 'left-[18px]' : 'left-0.5'}`}
/>
</button>
)
break
case 'select':
control = (
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(item.key, e.target.value)}
className="w-44 shrink-0 rounded-control border border-line-strong bg-surface px-2 py-1.5 text-[12px] text-ink outline-none focus:border-ink-3"
>
{!value && <option value="">(未设置)</option>}
{item.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)
break
case 'multiSelect': {
const arr = Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
control = (
<div className="flex shrink-0 flex-wrap justify-end gap-1">
{item.options.map((o) => {
const on = arr.includes(o.value)
return (
<button
key={o.value}
type="button"
onClick={() => onChange(item.key, on ? arr.filter((v) => v !== o.value) : [...arr, o.value])}
className={`rounded-control border px-2 py-1 text-[11px] transition-colors ${
on ? 'border-accent bg-accent/10 text-accent' : 'border-line text-ink-3 hover:text-ink-2'
}`}
>
{o.label}
</button>
)
})}
</div>
)
break
}
case 'slider':
control = (
<div className="flex shrink-0 items-center gap-2">
<input
type="range"
min={item.min}
max={item.max}
step={item.step ?? 1}
value={typeof value === 'number' ? value : item.min}
onChange={(e) => onChange(item.key, Number(e.target.value))}
className="w-36"
/>
<span className="w-10 text-right font-mono text-[11px] text-ink-3">
{typeof value === 'number' ? value : '—'}
</span>
</div>
)
break
case 'color':
control = (
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-[11px] text-ink-3">{typeof value === 'string' ? value : '—'}</span>
<input
type="color"
value={typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#888888'}
onChange={(e) => onChange(item.key, e.target.value)}
className="h-7 w-9 cursor-pointer rounded-control border border-line bg-transparent"
/>
</div>
)
break
case 'path':
case 'image': {
const mode = item.type === 'image' ? 'image' : item.mode
control = (
<div className="flex shrink-0 items-center gap-1.5">
{item.type === 'image' && imgUrls[item.key] && (
<img src={imgUrls[item.key]} alt="" className="h-8 w-8 rounded-control border border-line object-cover" />
)}
<span
className="max-w-44 truncate font-mono text-[11px] text-ink-3"
title={typeof value === 'string' ? value : undefined}
>
{typeof value === 'string' && value ? value : ('placeholder' in item && item.placeholder) || '未设置'}
</span>
<button
type="button"
disabled={picking}
onClick={() => void pickPath(mode, item.key)}
className="shrink-0 rounded-control border border-line-strong px-2 py-1 text-[11px] text-ink hover:bg-hover disabled:opacity-50"
>
选择
</button>
</div>
)
break
}
}
return (
<div key={key} className="flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5">
<div className="min-w-0">
<div className="text-[12.5px] text-ink">{item.label}</div>
{desc}
</div>
{control}
</div>
)
})}
</div>
)
}
/** 系统提示词预览:整段 pi 提示词是大文本,memo 化避免弹窗内任意状态变化触发重渲染;挂载耗时靠过渡提交兜住 */
const PromptPreview = memo(function PromptPreview({ loading, text }: { loading: boolean; text: string }): ReactNode {
return (
@@ -774,6 +1103,50 @@ export function SettingsModal({
onClose: () => void
}): JSX.Element | null {
const [tab, setTab] = useState<Tab>('model')
// ---- 插件设置(声明式 schema):plugins 列表 + schemas/values 快照(事件驱动刷新) ----
const plugins = usePluginList()
const [settingSchemas, setSettingSchemas] = useState<PluginSettingsSchemas>({})
const [settingValues, setSettingValues] = useState<PluginSettingsValuesMap>({})
useEffect(() => {
if (!open) return
let alive = true
const loadSchemas = (): void => {
window.api
.pluginsSettingsSchemas()
.then((s) => {
if (alive) setSettingSchemas(s)
})
.catch(() => undefined)
}
const loadValues = (): void => {
window.api
.pluginsSettingsValues()
.then((v) => {
if (alive) setSettingValues(v)
})
.catch(() => undefined)
}
loadSchemas()
loadValues()
const offSchemas = window.api.onPluginsSettingsSchemas(loadSchemas)
const offValues = window.api.onPluginsSettingsValues(loadValues)
return () => {
alive = false
offSchemas()
offValues()
}
}, [open])
// 已声明设置表单且打开了一级入口的插件 → 「Agent 能力」组追加导航项
const navGroups = useMemo(() => {
const pluginItems = plugins
.filter((p) => p.enabled && p.hasSettings && p.settingsNav)
.map((p) => ({
id: `plugin:${p.manifest.id}` as Tab,
label: p.manifest.name,
icon: pluginSettingsIcon(p.manifest.id, p.manifest.icon)
}))
return NAV.map((g) => (g.group === 'Agent 能力' ? { ...g, items: [...g.items, ...pluginItems] } : g))
}, [plugins])
const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
@@ -949,7 +1322,20 @@ export function SettingsModal({
if (!open) return null
const meta = TAB_META[tab]
const meta = tab.startsWith('plugin:')
? {
title: `${plugins.find((p) => `plugin:${p.manifest.id}` === tab)?.manifest.name ?? '插件'} 设置`,
desc: ''
}
: TAB_META[tab as Exclude<Tab, PluginSettingsTab>]
const handleSetSettingValue = (pluginId: string, key: string, value: unknown): void => {
setSettingValues((prev) => ({ ...prev, [pluginId]: { ...(prev[pluginId] ?? {}), [key]: value } }))
void window.api.pluginsSetSettingValue(pluginId, key, value)
}
const handleSetSettingsNav = (pluginId: string, on: boolean): void => {
void window.api.pluginsSetSettingsNav(pluginId, on)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/25" onClick={onClose}>
@@ -960,7 +1346,7 @@ export function SettingsModal({
>
{/* 左侧:分组导航 */}
<nav className="w-[196px] shrink-0 overflow-y-auto border-r border-line bg-inset/70 p-2.5">
{NAV.map((group) => (
{navGroups.map((group) => (
<div key={group.group} className="mb-2">
<div className="px-2 pb-1 pt-2 text-[11px] font-medium text-ink-3">{group.group}</div>
{group.items.map(({ id, label, icon: Icon }) => (
@@ -1058,9 +1444,34 @@ export function SettingsModal({
onToggle={togglePlugin}
devBuild={import.meta.env.DEV}
onOpenDir={(path) => void window.api.openPath(path)}
onOpenSettings={(pluginId) => setTab(`plugin:${pluginId}`)}
onNavToggle={handleSetSettingsNav}
/>
)}
{tab.startsWith('plugin:') &&
(() => {
const pid = tab.slice(7)
const schema = settingSchemas[pid]
if (!schema || schema.items.length === 0) {
return (
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
该插件未提供配置项(或插件已停用 / 移除)
</div>
)
}
return (
<div className="h-full min-h-0 overflow-y-auto pr-1">
<PluginSettingsForm
schema={schema}
values={settingValues[pid] ?? {}}
onChange={(key, value) => handleSetSettingValue(pid, key, value)}
onAction={(key) => void window.api.pluginsSettingsAction(pid, key)}
/>
</div>
)
})()}
{tab === 'prompt' && (
<div className="flex h-full min-h-0 flex-col">
<PromptPreview loading={promptLoading} text={prompt} />
+10
View File
@@ -26,6 +26,7 @@ import type {
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
export type {
AgentUiEvent,
@@ -120,6 +121,15 @@ declare global {
pluginsUiState: () => Promise<PluginsUiState>
pluginsDirs: () => Promise<{ user: string[]; project: string[]; global: string[] }>
pluginsReadRenderer: (id: string) => Promise<string>
pluginsSettingsSchemas: () => Promise<PluginSettingsSchemas>
pluginsSettingsValues: () => Promise<PluginSettingsValuesMap>
pluginsSetSettingValue: (id: string, key: string, value: unknown) => Promise<void>
pluginsSetSettingsNav: (id: string, on: boolean) => Promise<void>
pluginsSettingsAction: (id: string, key: string) => Promise<void>
pluginsPickPath: (mode: 'file' | 'directory' | 'image') => Promise<string | null>
pluginsReadImage: (path: string) => Promise<string | null>
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
pluginsReload: () => Promise<void>
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
+4
View File
@@ -378,6 +378,10 @@ export interface PluginInfo {
hasComponent: boolean
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
hasRenderer: boolean
/** 已声明设置表单(api.ui.registerSettings;空 schema 视为无) */
hasSettings: boolean
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
settingsNav: boolean
/** 装载/激活错误(不影响其他插件) */
error?: string
}