task(A1): plugin api v2 types and registry (additive)

This commit is contained in:
Agent Zero
2026-08-26 21:11:01 +02:00
parent cba9358235
commit 5d7fa23825
4 changed files with 332 additions and 0 deletions
+105
View File
@@ -9,14 +9,29 @@ import type {
ToolExtension, ToolExtension,
CommandExtension, CommandExtension,
ImportExportExtension, ImportExportExtension,
// API v2 (Task A1):
PluginV2,
ToolExtensionV2,
PanelExtension,
LibraryProviderExtension,
} from './types'; } from './types';
/** Interner Zustand eines registrierten V2-Plugins. */
interface PluginStateV2 {
plugin: PluginV2;
enabled: boolean;
loaded: boolean;
}
class PluginRegistryClass { class PluginRegistryClass {
private plugins = new Map<string, Plugin>(); private plugins = new Map<string, Plugin>();
private states = new Map<string, PluginState>(); private states = new Map<string, PluginState>();
private context: PluginContext | null = null; private context: PluginContext | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
// ── API v2 (Task A1) ─────────────────────────────────────
private v2Plugins = new Map<string, PluginStateV2>();
/** Set the plugin context (called once on app init) */ /** Set the plugin context (called once on app init) */
setContext(ctx: PluginContext) { setContext(ctx: PluginContext) {
this.context = ctx; this.context = ctx;
@@ -164,6 +179,96 @@ class PluginRegistryClass {
this.enable(id); this.enable(id);
} }
} }
for (const [id, state] of this.v2Plugins) {
if (state.plugin.manifest.enabledByDefault && !state.enabled) {
this.enableV2(id);
}
}
}
// ═══════════ API v2 (Task A1 — additiv) ═══════════
/** Registriert ein V2-Plugin. Doppelte IDs werden verworfen (Warnung). */
registerV2(plugin: PluginV2): void {
const { id } = plugin.manifest;
if (this.v2Plugins.has(id)) {
console.warn(`[PluginRegistry] V2-Plugin '${id}' bereits registriert verworfen`);
return;
}
this.v2Plugins.set(id, {
plugin,
enabled: false,
loaded: false,
});
this.notify();
}
/** Entfernt ein V2-Plugin (ruft onDestroy wenn geladen). */
unregisterV2(pluginId: string): void {
const state = this.v2Plugins.get(pluginId);
if (!state) return;
if (state.loaded) state.plugin.onDestroy?.();
this.v2Plugins.delete(pluginId);
this.notify();
}
/** Aktiviert ein V2-Plugin (onInit einmalig, dann onActivate). */
enableV2(pluginId: string): void {
const state = this.v2Plugins.get(pluginId);
if (!state || state.enabled) return;
state.enabled = true;
if (!state.loaded) {
state.plugin.onInit?.();
state.loaded = true;
}
state.plugin.onActivate?.();
this.notify();
}
/** Deaktiviert ein V2-Plugin. */
disableV2(pluginId: string): void {
const state = this.v2Plugins.get(pluginId);
if (!state || !state.enabled) return;
state.enabled = false;
state.plugin.onDeactivate?.();
this.notify();
}
/** Ist dieses V2-Plugin aktiv? */
isV2Enabled(pluginId: string): boolean {
return this.v2Plugins.get(pluginId)?.enabled ?? false;
}
/** Alle Tools aus AKTIVEN V2-Plugins (flach über alle Plugins). */
getToolsV2(): ToolExtensionV2[] {
const tools: ToolExtensionV2[] = [];
for (const state of this.v2Plugins.values()) {
if (state.enabled && state.plugin.tools) tools.push(...state.plugin.tools);
}
return tools;
}
/** Ein bestimmtes V2-Tool per ID (nur aus aktiven Plugins). */
getToolV2(toolId: string): ToolExtensionV2 | undefined {
return this.getToolsV2().find((t) => t.manifest.id === toolId);
}
/** Alle Panels aktiver V2-Plugins. */
getPanels(): PanelExtension[] {
const panels: PanelExtension[] = [];
for (const state of this.v2Plugins.values()) {
if (state.enabled && state.plugin.panels) panels.push(...state.plugin.panels);
}
return panels;
}
/** Alle Library-Provider aktiver V2-Plugins. */
getLibraryProviders(): LibraryProviderExtension[] {
const providers: LibraryProviderExtension[] = [];
for (const state of this.v2Plugins.values()) {
if (state.enabled && state.plugin.libraryProviders) providers.push(...state.plugin.libraryProviders);
}
return providers;
} }
} }
+11
View File
@@ -12,6 +12,17 @@ export type {
CommandExtension, CommandExtension,
ImportExportExtension, ImportExportExtension,
PropertyField, PropertyField,
// API v2 (Task A1):
PluginV2,
ToolExtensionV2,
ToolManifestPart,
ToolPointerEvent,
ToolContext,
PanelExtension,
LibraryProviderExtension,
LibFolder,
LibBlock,
PluginCategoryV2,
} from './types'; } from './types';
// Built-in plugins // Built-in plugins
+110
View File
@@ -116,3 +116,113 @@ export interface PluginState {
enabled: boolean; enabled: boolean;
loaded: boolean; loaded: boolean;
} }
// ═══════════════════════════════════════════════════════
// ─── API v2 (Task A1, additiv — siehe ROADMAP TEIL 2.4) ──
// ═══════════════════════════════════════════════════════
import type { Pt } from '../tools/modification/geometry';
/** Manifest-Teil eines V2-Werkzeugs. */
export interface ToolManifestPart {
id: string;
label: string;
icon: string;
ribbonTab: string;
shortcut?: string;
/** 'basic' = auch im Einfach-Modus sichtbar (Task H1). */
tags?: Array<'basic' | 'pro'>;
}
/** Normalisiertes Pointer-Event mit Welt- und Bildschirmkoordinaten. */
export interface ToolPointerEvent {
world: Pt;
screen: Pt;
button: number;
shift: boolean;
alt: boolean;
ctrl: boolean;
}
/** Kontext, den der Dispatcher einem aktiven V2-Tool übergibt. */
export interface ToolContext {
/** Aktueller Optionsstand für dieses Tool (aus toolStore). */
options: Record<string, unknown>;
/** Statuszeilentext setzen. */
setStatus(msg: string): void;
/** Live-Vorschau setzen (null = Vorschau entfernen). Ab A3 an Renderer angebunden. */
setPreview?(el: CADElement | null): void;
}
/** V2-Werkzeug mit eigenen Pointer-Handlern (ersetzt langfristig ToolExtension). */
export interface ToolExtensionV2 {
manifest: ToolManifestPart & { description?: string };
/** Optionsleisten-Schema (wiederverwendet PropertyField). */
optionsSchema: PropertyField[];
handlers: {
down?(e: ToolPointerEvent, ctx: ToolContext): void;
move?(e: ToolPointerEvent, ctx: ToolContext): void;
up?(e: ToolPointerEvent, ctx: ToolContext): void;
/** true = Tastendruck konsumiert. */
key?(e: KeyboardEvent, ctx: ToolContext): boolean;
/** ESC / Rechtsklick. */
cancel?(ctx: ToolContext): void;
};
}
/** Eigenes Sidebar-Panel eines Plugins. */
export interface PanelExtension {
id: string;
title: string;
slot: 'left' | 'right';
render(): HTMLElement;
}
/** Ordner einer Bibliothek (LibraryProvider). */
export interface LibFolder {
id: string;
label: string;
parentId: string | null;
}
/** Block einer Bibliothek (Metadaten; Payload provider-spezifisch). */
export interface LibBlock {
id: string;
folderId: string;
name: string;
tags?: string[];
thumbnail?: string; // dataURL
}
/** Liefert Kataloge — Kern der Plugin-erweiterbaren Library (Phase F). */
export interface LibraryProviderExtension {
id: string;
label: string;
listFolders(): Promise<LibFolder[]>;
listBlocks(folderId: string): Promise<LibBlock[]>;
getBlock(id: string): Promise<LibBlock>;
}
/** Erweiterte Manifest-Kategorie inkl. library. */
export type PluginCategoryV2 = PluginManifest['category'] | 'library';
/** Plugin-API v2 (registriert parallel zum alten Plugin-Interface). */
export interface PluginV2 {
manifest: {
id: string;
name: string;
version: string;
author: string;
description: string;
category: PluginCategoryV2;
enabledByDefault?: boolean;
minAppVersion?: string;
permissions?: Array<'canvas' | 'network' | 'storage'>;
};
tools?: ToolExtensionV2[];
panels?: PanelExtension[];
libraryProviders?: LibraryProviderExtension[];
onInit?(): void;
onActivate?(): void;
onDeactivate?(): void;
onDestroy?(): void;
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Plugin-API v2 Tests (Task A1).
* Deckt: registerV2 / enableV2 / disableV2 / isV2Enabled,
* getToolsV2 (nur aktive Plugins), getPanels, getLibraryProviders,
* doppelte IDs verworfen, initDefaults mit V2.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { pluginRegistry } from '../src/plugins/PluginRegistry';
import type {
PluginV2,
ToolExtensionV2,
} from '../src/plugins/types';
function makeTool(id: string): ToolExtensionV2 {
return {
manifest: { id, label: id, icon: '□', ribbonTab: 'tools' },
optionsSchema: [],
handlers: {},
};
}
function makePlugin(id: string, overrides: Partial<PluginV2> = {}): PluginV2 {
return {
manifest: {
id,
name: id,
version: '1.0.0',
author: 'test',
description: `Testplugin ${id}`,
category: 'tools',
enabledByDefault: false,
},
tools: [makeTool(`${id}-tool`)],
...overrides,
};
}
describe('pluginRegistry v2', () => {
// Testisolierung: Singleton-Registry nach jedem Test vollständig räumen
const registered: string[] = [];
function track(plugin: PluginV2): void {
registered.push(plugin.manifest.id);
pluginRegistry.registerV2(plugin);
}
afterEach(() => {
while (registered.length > 0) {
pluginRegistry.unregisterV2(registered.pop()!);
}
});
it('registriert und aktiviert ein V2-Plugin; Tools erscheinen in getToolsV2', () => {
track(makePlugin('p1'));
expect(pluginRegistry.getToolsV2().length).toBe(0); // noch deaktiviert
pluginRegistry.enableV2('p1');
expect(pluginRegistry.isV2Enabled('p1')).toBe(true);
const tools = pluginRegistry.getToolsV2();
expect(tools.length).toBe(1);
expect(tools[0].manifest.id).toBe('p1-tool');
expect(pluginRegistry.getToolV2('p1-tool')?.manifest.id).toBe('p1-tool');
});
it('deaktivierte Plugins liefern keine Tools/Panels/Provider', () => {
track(
makePlugin('p2', {
panels: [{ id: 'panel-x', title: 'X', slot: 'left', render: () => document.createElement('div') }],
libraryProviders: [
{
id: 'lib-x',
label: 'Lib X',
listFolders: async () => [],
listBlocks: async () => [],
getBlock: async () => ({ id: 'b', folderId: '', name: '' }),
},
],
}),
);
pluginRegistry.enableV2('p2');
expect(pluginRegistry.getPanels().length).toBe(1);
expect(pluginRegistry.getLibraryProviders().length).toBe(1);
pluginRegistry.disableV2('p2');
expect(pluginRegistry.isV2Enabled('p2')).toBe(false);
expect(pluginRegistry.getPanels().length).toBe(0);
expect(pluginRegistry.getLibraryProviders().length).toBe(0);
expect(pluginRegistry.getToolsV2().length).toBe(0);
});
it('verwirft doppelte Plugin-IDs ohne Fehler und behält das erste', () => {
const first = makePlugin('dup');
first.tools = [makeTool('first-tool')];
const second = makePlugin('dup');
second.tools = [makeTool('second-tool')];
track(first);
pluginRegistry.registerV2(second); // wird verworfen, muss nicht getrackt werden
pluginRegistry.enableV2('dup');
const ids = pluginRegistry.getToolsV2().map((t) => t.manifest.id);
expect(ids).toEqual(['first-tool']);
});
it('initDefaults aktiviert nur enabledByDefault-Plugins', () => {
track({ ...makePlugin('auto-on'), manifest: { ...makePlugin('auto-on').manifest, enabledByDefault: true } });
track(makePlugin('manual-off'));
pluginRegistry.initDefaults();
expect(pluginRegistry.isV2Enabled('auto-on')).toBe(true);
expect(pluginRegistry.isV2Enabled('manual-off')).toBe(false);
});
});