diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 1dbd677..d4a0a78 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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 源)不能直接 ,回传 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) diff --git a/apps/desktop/src/main/plugins/plugin-host.ts b/apps/desktop/src/main/plugins/plugin-host.ts index 98bbb68..383fc6c 100644 --- a/apps/desktop/src/main/plugins/plugin-host.ts +++ b/apps/desktop/src/main/plugins/plugin-host.ts @@ -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() /** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */ private projectExtensionsDir: string | null = null + /** 插件设置:声明 schema(激活时重新声明)/ 用户改过的值 / 一级导航开关 */ + private settingsSchemas = new Map() + private settingsValues = new Map() + private settingsNavSet = new Set() + /** 设置页动作/值变更的激活期回调(每次插件激活时重置) */ + private settingsActions = new Map void>>() + private settingsListeners = new Map void>>() + private valuesSaveTimer: NodeJS.Timeout | null = null /** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */ private rebuildRuntime: (() => Promise) | 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 + 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 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 { if (enabled) this.disabled.delete(id) else this.disabled.add(id) + this.persistPluginPrefs() + await this.reload() + } + + /** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */ + async setSettingsNav(id: string, on: boolean): Promise { + 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 = {} + 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) + } } } } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 0a81850..71a8ba3 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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 => ipcRenderer.invoke('plugins:readRenderer', id), + pluginsSettingsSchemas: (): Promise => ipcRenderer.invoke('plugins:settingsSchemas'), + pluginsSettingsValues: (): Promise => ipcRenderer.invoke('plugins:settingsValues'), + pluginsSetSettingValue: (id: string, key: string, value: unknown): Promise => + ipcRenderer.invoke('plugins:setSettingValue', id, key, value), + pluginsSetSettingsNav: (id: string, on: boolean): Promise => + ipcRenderer.invoke('plugins:setSettingsNav', id, on), + pluginsSettingsAction: (id: string, key: string): Promise => + ipcRenderer.invoke('plugins:settingsAction', id, key), + pluginsPickPath: (mode: 'file' | 'directory' | 'image'): Promise => + ipcRenderer.invoke('plugins:pickPath', mode), + pluginsReadImage: (path: string): Promise => 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 => ipcRenderer.invoke('plugins:setEnabled', id, enabled), pluginsReload: (): Promise => ipcRenderer.invoke('plugins:reload'), diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index 8301882..7b8e319 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -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 = { +const TAB_META: Record, { 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} {p.enabled ? '已启用' : '已停用'} + {p.hasSettings && p.enabled && ( + <> + + + 一级入口 + + + + )} + + ) + } + + let control: ReactNode = null + switch (item.type) { + case 'string': + control = ( + onChange(item.key, e.target.value)} + className={`${inputCls} w-56 shrink-0`} + /> + ) + break + case 'text': + return ( +
+
{item.label}
+ {desc} +