From 525863087fb05b6278acf30e0a387a269ade5502 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:10:39 +0000 Subject: [PATCH 01/17] =?UTF-8?q?feat(chat):=20database=20foundation=20?= =?UTF-8?q?=E2=80=94=20conversations=20and=20messages=20tables=20+=20model?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Models/ChatConversation.php | 58 +++++++++++++++++++ app/Models/ChatMessage.php | 49 ++++++++++++++++ ...300001_create_chat_conversations_table.php | 31 ++++++++++ ...3_15_300002_create_chat_messages_table.php | 33 +++++++++++ 4 files changed, 171 insertions(+) create mode 100644 app/Models/ChatConversation.php create mode 100644 app/Models/ChatMessage.php create mode 100644 database/migrations/2026_03_15_300001_create_chat_conversations_table.php create mode 100644 database/migrations/2026_03_15_300002_create_chat_messages_table.php diff --git a/app/Models/ChatConversation.php b/app/Models/ChatConversation.php new file mode 100644 index 0000000..77f417e --- /dev/null +++ b/app/Models/ChatConversation.php @@ -0,0 +1,58 @@ + $messages + */ +class ChatConversation extends Model +{ + use HasFactory, HasUlids; + + protected $fillable = [ + 'space_id', + 'user_id', + 'title', + 'context', + 'pending_action', + 'last_active_at', + ]; + + protected $casts = [ + 'context' => 'array', + 'pending_action' => 'array', + 'last_active_at' => 'datetime', + ]; + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function messages(): HasMany + { + return $this->hasMany(ChatMessage::class, 'conversation_id'); + } +} diff --git a/app/Models/ChatMessage.php b/app/Models/ChatMessage.php new file mode 100644 index 0000000..c9561ed --- /dev/null +++ b/app/Models/ChatMessage.php @@ -0,0 +1,49 @@ + 'array', + 'actions_taken' => 'array', + 'cost_usd' => 'decimal:6', + ]; + + public function conversation(): BelongsTo + { + return $this->belongsTo(ChatConversation::class, 'conversation_id'); + } +} diff --git a/database/migrations/2026_03_15_300001_create_chat_conversations_table.php b/database/migrations/2026_03_15_300001_create_chat_conversations_table.php new file mode 100644 index 0000000..ad6dfdb --- /dev/null +++ b/database/migrations/2026_03_15_300001_create_chat_conversations_table.php @@ -0,0 +1,31 @@ +char('id', 26)->primary(); + $table->string('space_id', 26)->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('title', 200)->nullable(); + $table->json('context')->nullable(); + $table->json('pending_action')->nullable(); + $table->timestamp('last_active_at')->nullable(); + $table->timestamps(); + + $table->index(['space_id', 'user_id']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('chat_conversations'); + } +}; diff --git a/database/migrations/2026_03_15_300002_create_chat_messages_table.php b/database/migrations/2026_03_15_300002_create_chat_messages_table.php new file mode 100644 index 0000000..776565a --- /dev/null +++ b/database/migrations/2026_03_15_300002_create_chat_messages_table.php @@ -0,0 +1,33 @@ +char('id', 26)->primary(); + $table->string('conversation_id', 26)->index(); + $table->enum('role', ['user', 'assistant', 'system']); + $table->longText('content'); + $table->json('intent')->nullable(); + $table->json('actions_taken')->nullable(); + $table->unsignedInteger('input_tokens')->nullable(); + $table->unsignedInteger('output_tokens')->nullable(); + $table->decimal('cost_usd', 10, 6)->nullable(); + $table->timestamps(); + + $table->index(['conversation_id', 'created_at']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('chat_messages'); + } +}; From 099a50ea71f48bfe3134aef37c0dfc1c28edee59 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:10:46 +0000 Subject: [PATCH 02/17] =?UTF-8?q?feat(plugins):=20plugin=20kernel=20?= =?UTF-8?q?=E2=80=94=20manifest,=20base=20provider,=20hook=20registry,=20l?= =?UTF-8?q?oader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PluginManifest: value object parsing numen-plugin.json with API version validation - PluginServiceProvider: abstract base with registerHooks(), lifecycle methods (install/activate/deactivate/uninstall), setting() and pluginPath() helpers - HookRegistry: singleton bus with typed registration methods: registerPipelineStage(), registerLLMProvider(), registerImageProvider(), onContentEvent(), addAdminMenuItem(), addAdminWidget(), registerVueComponent() - PluginLoader: discovers plugins from Composer installed.json (type=numen-plugin) and local plugin_paths, validates API version constraint, checks DB active status, boots providers - config/plugins.php: plugin_paths, plugin_api_version, max_plugins settings - AppServiceProvider: registers HookRegistry and PluginLoader as singletons, boots PluginLoader in boot() --- app/Plugin/HookRegistry.php | 240 ++++++++++++++++++++++ app/Plugin/PluginLoader.php | 290 +++++++++++++++++++++++++++ app/Plugin/PluginManifest.php | 110 ++++++++++ app/Plugin/PluginServiceProvider.php | 132 ++++++++++++ app/Providers/AppServiceProvider.php | 8 + config/plugins.php | 72 +++++++ 6 files changed, 852 insertions(+) create mode 100644 app/Plugin/HookRegistry.php create mode 100644 app/Plugin/PluginLoader.php create mode 100644 app/Plugin/PluginManifest.php create mode 100644 app/Plugin/PluginServiceProvider.php create mode 100644 config/plugins.php diff --git a/app/Plugin/HookRegistry.php b/app/Plugin/HookRegistry.php new file mode 100644 index 0000000..44cec22 --- /dev/null +++ b/app/Plugin/HookRegistry.php @@ -0,0 +1,240 @@ +> */ + private array $pipelineStages = []; + + /** @var array */ + private array $llmProviders = []; + + /** @var array */ + private array $imageProviders = []; + + /** @var array> */ + private array $contentEventListeners = []; + + /** @var array */ + private array $adminMenuItems = []; + + /** @var array}> */ + private array $adminWidgets = []; + + /** @var array */ + private array $vueComponents = []; + + // ── Pipeline stages ──────────────────────────────────────────────────────── + + /** + * Register a named pipeline stage processor. + * + * The Closure receives (array $context, array $stageConfig): array $context. + */ + public function registerPipelineStage(string $stageName, Closure $handler): void + { + $this->pipelineStages[$stageName][] = $handler; + } + + /** + * Get all handlers registered for a pipeline stage name. + * + * @return array + */ + public function getPipelineStageHandlers(string $stageName): array + { + return $this->pipelineStages[$stageName] ?? []; + } + + /** + * Get all registered pipeline stage names. + * + * @return array + */ + public function getRegisteredPipelineStages(): array + { + return array_keys($this->pipelineStages); + } + + // ── LLM providers ────────────────────────────────────────────────────────── + + /** + * Register a custom LLM provider factory. + * + * The Closure receives (array $config): LLMProviderInterface. + */ + public function registerLLMProvider(string $providerName, Closure $factory): void + { + $this->llmProviders[$providerName] = $factory; + } + + /** + * Get the factory for an LLM provider, or null if not registered. + */ + public function getLLMProviderFactory(string $providerName): ?Closure + { + return $this->llmProviders[$providerName] ?? null; + } + + /** + * Get all registered LLM provider names. + * + * @return array + */ + public function getRegisteredLLMProviders(): array + { + return array_keys($this->llmProviders); + } + + // ── Image providers ──────────────────────────────────────────────────────── + + /** + * Register a custom image generation provider factory. + * + * The Closure receives (array $config): ImageProviderInterface. + */ + public function registerImageProvider(string $providerName, Closure $factory): void + { + $this->imageProviders[$providerName] = $factory; + } + + /** + * Get the factory for an image provider, or null if not registered. + */ + public function getImageProviderFactory(string $providerName): ?Closure + { + return $this->imageProviders[$providerName] ?? null; + } + + /** + * Get all registered image provider names. + * + * @return array + */ + public function getRegisteredImageProviders(): array + { + return array_keys($this->imageProviders); + } + + // ── Content events ───────────────────────────────────────────────────────── + + /** + * Listen to a content lifecycle event. + * + * $eventName examples: 'content.created', 'content.published', 'content.deleted' + * The Closure receives (mixed $payload): void. + */ + public function onContentEvent(string $eventName, Closure $listener): void + { + $this->contentEventListeners[$eventName][] = $listener; + } + + /** + * Get all listeners for a content event. + * + * @return array + */ + public function getContentEventListeners(string $eventName): array + { + return $this->contentEventListeners[$eventName] ?? []; + } + + /** + * Dispatch a content event to all registered listeners. + */ + public function dispatchContentEvent(string $eventName, mixed $payload): void + { + foreach ($this->getContentEventListeners($eventName) as $listener) { + $listener($payload); + } + } + + // ── Admin menu items ─────────────────────────────────────────────────────── + + /** + * Add an item to the admin navigation menu. + * + * @param array{id: string, label: string, route: string, icon?: string|null, weight?: int} $item + */ + public function addAdminMenuItem(array $item): void + { + $this->adminMenuItems[] = [ + 'id' => $item['id'], + 'label' => $item['label'], + 'route' => $item['route'], + 'icon' => $item['icon'] ?? null, + 'weight' => $item['weight'] ?? 100, + ]; + } + + /** + * Get all registered admin menu items, sorted by weight. + * + * @return array + */ + public function getAdminMenuItems(): array + { + $items = $this->adminMenuItems; + usort($items, fn ($a, $b) => $a['weight'] <=> $b['weight']); + + return $items; + } + + // ── Admin widgets ────────────────────────────────────────────────────────── + + /** + * Add a Vue widget to the admin dashboard. + * + * @param array{id: string, component: string, props?: array} $widget + */ + public function addAdminWidget(array $widget): void + { + $this->adminWidgets[] = [ + 'id' => $widget['id'], + 'component' => $widget['component'], + 'props' => $widget['props'] ?? [], + ]; + } + + /** + * Get all registered admin dashboard widgets. + * + * @return array}> + */ + public function getAdminWidgets(): array + { + return $this->adminWidgets; + } + + // ── Vue components ───────────────────────────────────────────────────────── + + /** + * Register a Vue component that should be globally available in the frontend. + * + * @param string $name Vue component name (e.g. 'MyPluginWidget') + * @param string $importPath Absolute path or NPM package reference to the .vue file + */ + public function registerVueComponent(string $name, string $importPath): void + { + $this->vueComponents[$name] = $importPath; + } + + /** + * Get all registered Vue component definitions. + * + * @return array + */ + public function getVueComponents(): array + { + return $this->vueComponents; + } +} diff --git a/app/Plugin/PluginLoader.php b/app/Plugin/PluginLoader.php new file mode 100644 index 0000000..3fa5e85 --- /dev/null +++ b/app/Plugin/PluginLoader.php @@ -0,0 +1,290 @@ + */ + private array $loaded = []; + + public function __construct( + private readonly Application $app, + ) {} + + /** + * Discover all valid, active plugins and boot their providers. + */ + public function boot(): void + { + $apiVersion = (string) config('plugins.plugin_api_version', '1.0'); + $maxPlugins = (int) config('plugins.max_plugins', 50); + + $manifests = $this->discover(); + + $booted = 0; + foreach ($manifests as $manifest) { + if ($booted >= $maxPlugins) { + Log::warning('[PluginLoader] Max plugin limit reached, skipping remaining plugins.', [ + 'max' => $maxPlugins, + ]); + break; + } + + if (! $manifest->satisfiesApiVersion($apiVersion)) { + Log::warning('[PluginLoader] Plugin API version mismatch, skipping.', [ + 'plugin' => $manifest->name, + 'plugin_api_version' => $manifest->apiVersion, + 'required_api_version' => $apiVersion, + ]); + + continue; + } + + if (! $this->isActive($manifest)) { + continue; + } + + try { + $this->bootProvider($manifest); + $this->loaded[] = $manifest; + $booted++; + } catch (Throwable $e) { + Log::error('[PluginLoader] Failed to boot plugin.', [ + 'plugin' => $manifest->name, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Return all successfully booted plugin manifests. + * + * @return array + */ + public function getLoaded(): array + { + return $this->loaded; + } + + // ── Discovery ────────────────────────────────────────────────────────────── + + /** + * Collect all discoverable PluginManifest instances. + * + * @return array + */ + private function discover(): array + { + return array_merge( + $this->discoverFromComposer(), + $this->discoverFromPluginPaths(), + ); + } + + /** + * Parse vendor/composer/installed.json for packages of type "numen-plugin". + * + * @return array + */ + private function discoverFromComposer(): array + { + $installedJson = base_path('vendor/composer/installed.json'); + if (! file_exists($installedJson)) { + return []; + } + + $raw = file_get_contents($installedJson); + if ($raw === false) { + return []; + } + + /** @var array{packages?: array}>}|null $installed */ + $installed = json_decode($raw, true); + + if (! is_array($installed)) { + return []; + } + + $packages = $installed['packages'] ?? (array_values($installed) ?: []); + + $manifests = []; + foreach ($packages as $package) { + if (! is_array($package)) { + continue; + } + + if (($package['type'] ?? '') !== 'numen-plugin') { + continue; + } + + $packageName = $package['name'] ?? ''; + if ($packageName === '') { + continue; + } + + // Check for numen-plugin.json in the vendor directory + $pluginJsonPath = base_path('vendor/'.$packageName.'/numen-plugin.json'); + if (file_exists($pluginJsonPath)) { + try { + $manifests[] = PluginManifest::fromFile($pluginJsonPath); + } catch (InvalidArgumentException $e) { + Log::warning('[PluginLoader] Invalid Composer plugin manifest.', [ + 'package' => $packageName, + 'error' => $e->getMessage(), + ]); + } + + continue; + } + + // Fall back to extras.numen-plugin key in composer.json + /** @var array|null $extras */ + $extras = is_array($package['extra'] ?? null) ? $package['extra']['numen-plugin'] ?? null : null; + if (is_array($extras)) { + $extras['name'] ??= $packageName; + $extras['version'] ??= $package['version'] ?? '0.0.0'; + try { + $manifests[] = PluginManifest::fromArray($extras); + } catch (InvalidArgumentException $e) { + Log::warning('[PluginLoader] Invalid Composer plugin extras.', [ + 'package' => $packageName, + 'error' => $e->getMessage(), + ]); + } + } + } + + return $manifests; + } + + /** + * Scan directories listed in config('plugins.plugin_paths') for + * numen-plugin.json files. + * + * @return array + */ + private function discoverFromPluginPaths(): array + { + /** @var array|mixed $pluginPaths */ + $pluginPaths = config('plugins.plugin_paths', []); + if (! is_array($pluginPaths)) { + return []; + } + + $manifests = []; + foreach ($pluginPaths as $basePath) { + $basePath = (string) $basePath; + if (! is_dir($basePath)) { + continue; + } + + // Each sub-directory is a potential plugin + $entries = scandir($basePath); + if ($entries === false) { + continue; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $pluginDir = $basePath.DIRECTORY_SEPARATOR.$entry; + if (! is_dir($pluginDir)) { + continue; + } + + $manifestPath = $pluginDir.DIRECTORY_SEPARATOR.'numen-plugin.json'; + if (! file_exists($manifestPath)) { + continue; + } + + try { + $manifests[] = PluginManifest::fromFile($manifestPath); + } catch (InvalidArgumentException $e) { + Log::warning('[PluginLoader] Invalid local plugin manifest.', [ + 'path' => $manifestPath, + 'error' => $e->getMessage(), + ]); + } + } + } + + return $manifests; + } + + // ── Active check ─────────────────────────────────────────────────────────── + + /** + * Return true when the plugin is considered active. + * + * If the `plugins` table exists, the plugin must have a row with + * status = 'active'. When the table does not yet exist (e.g. fresh + * install before migrations), all discovered plugins are considered active + * so the application can still boot. + */ + private function isActive(PluginManifest $manifest): bool + { + if (! Schema::hasTable('plugins')) { + // Migrations haven't run yet — treat all plugins as active + return true; + } + + /** @var object{status: string}|null $row */ + $row = DB::table('plugins') + ->where('name', $manifest->name) + ->first(['status']); + + return $row !== null && $row->status === 'active'; + } + + // ── Provider booting ─────────────────────────────────────────────────────── + + /** + * Instantiate and register the plugin's service provider. + * + * @throws InvalidArgumentException + */ + private function bootProvider(PluginManifest $manifest): void + { + $class = $manifest->providerClass; + + if (! class_exists($class)) { + throw new InvalidArgumentException( + "Plugin provider class [{$class}] for plugin [{$manifest->name}] does not exist." + ); + } + + if (! is_a($class, PluginServiceProvider::class, true)) { + throw new InvalidArgumentException( + "Plugin provider [{$class}] must extend ".PluginServiceProvider::class.'.' + ); + } + + /** @var PluginServiceProvider $provider */ + $provider = new $class($this->app); + $provider->setManifest($manifest); + + $this->app->register($provider); + } +} diff --git a/app/Plugin/PluginManifest.php b/app/Plugin/PluginManifest.php new file mode 100644 index 0000000..1913f93 --- /dev/null +++ b/app/Plugin/PluginManifest.php @@ -0,0 +1,110 @@ + $hooks */ + /** @param array $permissions */ + /** @param array $settingsSchema */ + public function __construct( + public readonly string $name, + public readonly string $version, + public readonly string $displayName, + public readonly string $providerClass, + public readonly string $apiVersion, + public readonly array $hooks = [], + public readonly array $permissions = [], + public readonly array $settingsSchema = [], + public readonly string $description = '', + public readonly string $author = '', + ) {} + + /** + * Parse a numen-plugin.json file and return a PluginManifest. + * + * @throws InvalidArgumentException + */ + public static function fromFile(string $path): self + { + if (! file_exists($path)) { + throw new InvalidArgumentException("Plugin manifest not found: {$path}"); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new InvalidArgumentException("Cannot read plugin manifest: {$path}"); + } + + /** @var array|null $data */ + $data = json_decode($raw, true); + if (! is_array($data)) { + throw new InvalidArgumentException("Invalid JSON in plugin manifest: {$path}"); + } + + return self::fromArray($data, dirname($path)); + } + + /** + * Parse from an array (e.g. from Composer installed.json extras). + * + * @param array $data + * + * @throws InvalidArgumentException + */ + public static function fromArray(array $data, string $basePath = ''): self + { + foreach (['name', 'version', 'display_name', 'provider', 'api_version'] as $required) { + if (empty($data[$required])) { + throw new InvalidArgumentException("Plugin manifest missing required field: {$required}"); + } + } + + /** @var string $name */ + $name = $data['name']; + /** @var string $version */ + $version = $data['version']; + /** @var string $displayName */ + $displayName = $data['display_name']; + /** @var string $providerClass */ + $providerClass = $data['provider']; + /** @var string $apiVersion */ + $apiVersion = $data['api_version']; + + return new self( + name: $name, + version: $version, + displayName: $displayName, + providerClass: $providerClass, + apiVersion: $apiVersion, + hooks: is_array($data['hooks'] ?? null) ? $data['hooks'] : [], + permissions: is_array($data['permissions'] ?? null) ? $data['permissions'] : [], + settingsSchema: is_array($data['settings_schema'] ?? null) ? $data['settings_schema'] : [], + description: is_string($data['description'] ?? null) ? $data['description'] : '', + author: is_string($data['author'] ?? null) ? $data['author'] : '', + ); + } + + /** + * Check whether this plugin's API version satisfies a constraint. + * + * Supports simple ^major.minor constraints: the major must match and + * the plugin's minor must be >= the required minor. + */ + public function satisfiesApiVersion(string $requiredApiVersion): bool + { + // Strip leading ^ if present + $required = ltrim($requiredApiVersion, '^~'); + $pluginVer = ltrim($this->apiVersion, '^~'); + + [$reqMajor, $reqMinor] = array_map('intval', explode('.', $required.'.0')); + [$plugMajor, $plugMinor] = array_map('intval', explode('.', $pluginVer.'.0')); + + return $plugMajor === $reqMajor && $plugMinor >= $reqMinor; + } +} diff --git a/app/Plugin/PluginServiceProvider.php b/app/Plugin/PluginServiceProvider.php new file mode 100644 index 0000000..b7744c9 --- /dev/null +++ b/app/Plugin/PluginServiceProvider.php @@ -0,0 +1,132 @@ +app->make(HookRegistry::class); + $this->registerHooks($registry); + } + + /** + * Set the manifest on this provider (called by PluginLoader). + * + * @internal + */ + public function setManifest(PluginManifest $manifest): void + { + $this->manifest = $manifest; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /** + * Read a plugin setting value from the application config. + * + * Settings are stored under plugins.. in the config. + */ + protected function setting(string $key, mixed $default = null): mixed + { + $configKey = 'plugins.plugin_settings.'.$this->manifest->name.'.'.$key; + + return config($configKey, $default); + } + + /** + * Get the absolute filesystem path to this plugin's root directory. + * + * If the plugin was loaded from a plugin_paths directory, returns that path. + * Composer-installed plugins use vendor/. + */ + protected function pluginPath(string $relative = ''): string + { + // Derive the path from Composer's vendor directory for the plugin package + // (plugin name format: vendor/package → vendor/vendor/package) + $vendorPath = base_path('vendor/'.str_replace('/', DIRECTORY_SEPARATOR, $this->manifest->name)); + + // Fall back to checking configured plugin_paths + if (! is_dir($vendorPath)) { + $pluginPaths = config('plugins.plugin_paths', []); + if (is_array($pluginPaths)) { + foreach ($pluginPaths as $basePath) { + $candidate = rtrim((string) $basePath, '/').'/'.$this->manifest->name; + if (is_dir($candidate)) { + $vendorPath = $candidate; + break; + } + } + } + } + + if ($relative === '') { + return $vendorPath; + } + + return $vendorPath.DIRECTORY_SEPARATOR.ltrim($relative, '/\\'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d5ee79d..5d102d2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,8 @@ use App\Listeners\RemoveFromSearchIndex; use App\Models\Content; use App\Models\Setting; +use App\Plugin\HookRegistry; +use App\Plugin\PluginLoader; use App\Policies\ContentPolicy; use App\Services\AI\CostTracker; use App\Services\AI\ImageManager; @@ -40,6 +42,10 @@ class AppServiceProvider extends ServiceProvider public function register(): void { // ── Authorization ────────────────────────────────────────────────── + // ── Plugin system ────────────────────────────────────────────────────── + $this->app->singleton(HookRegistry::class); + $this->app->singleton(PluginLoader::class, fn ($app) => new PluginLoader($app)); + $this->app->singleton(AuthorizationService::class); $this->app->singleton(PermissionRegistrar::class); @@ -111,6 +117,8 @@ public function boot(): void // Load DB settings into config (overrides .env defaults) Setting::loadIntoConfig(); + // Boot plugin system + $this->app->make(PluginLoader::class)->boot(); // Register search event listeners Event::listen(ContentPublished::class, IndexContentForSearch::class); diff --git a/config/plugins.php b/config/plugins.php new file mode 100644 index 0000000..02ba15a --- /dev/null +++ b/config/plugins.php @@ -0,0 +1,72 @@ + array_filter( + array_map( + 'trim', + explode(',', (string) env('NUMEN_PLUGIN_PATHS', '')), + ), + fn (string $p) => $p !== '', + ), + + /* + |-------------------------------------------------------------------------- + | Plugin API Version + |-------------------------------------------------------------------------- + | + | The API version that this Numen installation supports. Plugins declare + | an `api_version` in their manifest; if it doesn't satisfy the constraint + | defined here, the plugin will not be loaded. + | + | Format: "major.minor" — plugin major must match, plugin minor >= required minor. + | + */ + 'plugin_api_version' => env('NUMEN_PLUGIN_API_VERSION', '1.0'), + + /* + |-------------------------------------------------------------------------- + | Maximum Active Plugins + |-------------------------------------------------------------------------- + | + | Hard limit on the number of plugins that can be active simultaneously. + | This protects against runaway resource usage on shared hosting environments. + | + */ + 'max_plugins' => (int) env('NUMEN_MAX_PLUGINS', 50), + + /* + |-------------------------------------------------------------------------- + | Plugin Settings Store + |-------------------------------------------------------------------------- + | + | Plugin-specific settings are namespaced under this key. + | Each plugin's settings are stored as plugins.plugin_settings..* + | This section is populated at runtime from the DB settings table. + | + */ + 'plugin_settings' => [], + +]; From be67c497d3ea2fc95e6d511632e441535972301b Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:15:18 +0000 Subject: [PATCH 03/17] feat(plugins): database schema, models, lifecycle manager, events --- app/Events/Plugin/PluginActivated.php | 16 + app/Events/Plugin/PluginDeactivated.php | 16 + app/Events/Plugin/PluginInstalled.php | 16 + app/Events/Plugin/PluginUninstalled.php | 16 + app/Models/Plugin.php | 90 ++++++ app/Models/PluginSetting.php | 44 +++ app/Plugin/PluginManager.php | 299 ++++++++++++++++++ ...2026_03_15_300001_create_plugins_table.php | 35 ++ ...15_300002_create_plugin_settings_table.php | 32 ++ 9 files changed, 564 insertions(+) create mode 100644 app/Events/Plugin/PluginActivated.php create mode 100644 app/Events/Plugin/PluginDeactivated.php create mode 100644 app/Events/Plugin/PluginInstalled.php create mode 100644 app/Events/Plugin/PluginUninstalled.php create mode 100644 app/Models/Plugin.php create mode 100644 app/Models/PluginSetting.php create mode 100644 app/Plugin/PluginManager.php create mode 100644 database/migrations/2026_03_15_300001_create_plugins_table.php create mode 100644 database/migrations/2026_03_15_300002_create_plugin_settings_table.php diff --git a/app/Events/Plugin/PluginActivated.php b/app/Events/Plugin/PluginActivated.php new file mode 100644 index 0000000..74f2488 --- /dev/null +++ b/app/Events/Plugin/PluginActivated.php @@ -0,0 +1,16 @@ + $manifest + * @property string $status + * @property \Carbon\Carbon|null $installed_at + * @property \Carbon\Carbon|null $activated_at + * @property string|null $error_message + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property \Carbon\Carbon|null $deleted_at + * @property-read \Illuminate\Database\Eloquent\Collection $settings + */ +class Plugin extends Model +{ + use HasUlids, SoftDeletes; + + protected $fillable = [ + 'name', + 'display_name', + 'version', + 'description', + 'manifest', + 'status', + 'installed_at', + 'activated_at', + 'error_message', + ]; + + /** @var array */ + protected $casts = [ + 'manifest' => 'array', + 'installed_at' => 'datetime', + 'activated_at' => 'datetime', + ]; + + // ── Relationships ────────────────────────────────────────────────────────── + + public function settings(): HasMany + { + return $this->hasMany(PluginSetting::class); + } + + // ── Scopes ───────────────────────────────────────────────────────────────── + + public function scopeActive(Builder $query): Builder + { + return $query->where('status', 'active'); + } + + public function scopeInstalled(Builder $query): Builder + { + return $query->whereIn('status', ['installed', 'active', 'inactive']); + } + + public function scopeDiscovered(Builder $query): Builder + { + return $query->where('status', 'discovered'); + } + + public function scopeInactive(Builder $query): Builder + { + return $query->where('status', 'inactive'); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function isInstalled(): bool + { + return in_array($this->status, ['installed', 'active', 'inactive'], true); + } +} diff --git a/app/Models/PluginSetting.php b/app/Models/PluginSetting.php new file mode 100644 index 0000000..5702342 --- /dev/null +++ b/app/Models/PluginSetting.php @@ -0,0 +1,44 @@ + */ + protected $casts = [ + 'value' => 'array', + 'is_secret' => 'boolean', + ]; + + // ── Relationships ────────────────────────────────────────────────────────── + + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } +} diff --git a/app/Plugin/PluginManager.php b/app/Plugin/PluginManager.php new file mode 100644 index 0000000..e5ec227 --- /dev/null +++ b/app/Plugin/PluginManager.php @@ -0,0 +1,299 @@ + Newly upserted Plugin models. + */ + public function discover(): array + { + $manifests = $this->loader->getLoaded(); + + // Re-trigger discovery if loader hasn't booted yet (e.g. CLI context) + if (empty($manifests)) { + $this->loader->boot(); + $manifests = $this->loader->getLoaded(); + } + + $upserted = []; + + foreach ($manifests as $manifest) { + /** @var Plugin $plugin */ + $plugin = Plugin::withTrashed()->firstOrNew(['name' => $manifest->name]); + + // Restore soft-deleted plugins on re-discovery + if ($plugin->trashed()) { + $plugin->restore(); + } + + // Only update status to discovered if not yet installed + if (! $plugin->exists || $plugin->status === 'discovered') { + $plugin->status = 'discovered'; + } + + $plugin->fill([ + 'id' => $plugin->id ?? (string) Str::ulid(), + 'display_name' => $manifest->displayName, + 'version' => $manifest->version, + 'description' => $manifest->description, + 'manifest' => [ + 'name' => $manifest->name, + 'version' => $manifest->version, + 'display_name' => $manifest->displayName, + 'provider_class' => $manifest->providerClass, + 'api_version' => $manifest->apiVersion, + 'hooks' => $manifest->hooks, + 'permissions' => $manifest->permissions, + 'settings_schema' => $manifest->settingsSchema, + 'author' => $manifest->author, + ], + ]); + + $plugin->save(); + $upserted[] = $plugin; + + Log::debug('[PluginManager] Discovered plugin.', ['name' => $manifest->name]); + } + + return $upserted; + } + + // ── Install ──────────────────────────────────────────────────────────────── + + /** + * Install a discovered plugin: call its install() hook, run migrations, persist. + * + * @throws InvalidArgumentException When the plugin is not found. + * @throws RuntimeException When installation fails. + */ + public function install(string $name): Plugin + { + $plugin = $this->resolvePlugin($name); + + if ($plugin->isInstalled()) { + throw new RuntimeException("Plugin [{$name}] is already installed."); + } + + try { + $provider = $this->resolveProvider($plugin); + $provider->install(); + + Artisan::call('migrate', ['--force' => true]); + + $plugin->status = 'installed'; + $plugin->installed_at = Carbon::now(); + $plugin->error_message = null; + $plugin->save(); + + Event::dispatch(new PluginInstalled($plugin)); + + Log::info('[PluginManager] Plugin installed.', ['name' => $name]); + } catch (Throwable $e) { + $plugin->status = 'error'; + $plugin->error_message = $e->getMessage(); + $plugin->save(); + + throw new RuntimeException("Plugin installation failed for [{$name}]: ".$e->getMessage(), 0, $e); + } + + return $plugin; + } + + // ── Activate ─────────────────────────────────────────────────────────────── + + /** + * Activate an installed (or inactive) plugin. + * + * @throws InvalidArgumentException + * @throws RuntimeException + */ + public function activate(string $name): Plugin + { + $plugin = $this->resolvePlugin($name); + + if ($plugin->status === 'active') { + throw new RuntimeException("Plugin [{$name}] is already active."); + } + + if (! $plugin->isInstalled()) { + throw new RuntimeException("Plugin [{$name}] must be installed before it can be activated."); + } + + try { + $provider = $this->resolveProvider($plugin); + $provider->activate(); + + $plugin->status = 'active'; + $plugin->activated_at = Carbon::now(); + $plugin->error_message = null; + $plugin->save(); + + Event::dispatch(new PluginActivated($plugin)); + + Log::info('[PluginManager] Plugin activated.', ['name' => $name]); + } catch (Throwable $e) { + $plugin->status = 'error'; + $plugin->error_message = $e->getMessage(); + $plugin->save(); + + throw new RuntimeException("Plugin activation failed for [{$name}]: ".$e->getMessage(), 0, $e); + } + + return $plugin; + } + + // ── Deactivate ───────────────────────────────────────────────────────────── + + /** + * Deactivate an active plugin. + * + * @throws InvalidArgumentException + * @throws RuntimeException + */ + public function deactivate(string $name): Plugin + { + $plugin = $this->resolvePlugin($name); + + if ($plugin->status !== 'active') { + throw new RuntimeException("Plugin [{$name}] is not active."); + } + + try { + $provider = $this->resolveProvider($plugin); + $provider->deactivate(); + + $plugin->status = 'inactive'; + $plugin->error_message = null; + $plugin->save(); + + Event::dispatch(new PluginDeactivated($plugin)); + + Log::info('[PluginManager] Plugin deactivated.', ['name' => $name]); + } catch (Throwable $e) { + $plugin->status = 'error'; + $plugin->error_message = $e->getMessage(); + $plugin->save(); + + throw new RuntimeException("Plugin deactivation failed for [{$name}]: ".$e->getMessage(), 0, $e); + } + + return $plugin; + } + + // ── Uninstall ────────────────────────────────────────────────────────────── + + /** + * Uninstall a plugin: call its uninstall() hook, roll back migrations, soft-delete. + * + * @throws InvalidArgumentException + * @throws RuntimeException + */ + public function uninstall(string $name): Plugin + { + $plugin = $this->resolvePlugin($name); + + if (! $plugin->isInstalled()) { + throw new RuntimeException("Plugin [{$name}] is not installed."); + } + + try { + $provider = $this->resolveProvider($plugin); + $provider->uninstall(); + + Artisan::call('migrate:rollback', ['--force' => true, '--step' => 1]); + + $plugin->status = 'discovered'; + $plugin->installed_at = null; + $plugin->activated_at = null; + $plugin->error_message = null; + $plugin->save(); + + Event::dispatch(new PluginUninstalled($plugin)); + + $plugin->delete(); // soft-delete + + Log::info('[PluginManager] Plugin uninstalled.', ['name' => $name]); + } catch (Throwable $e) { + $plugin->status = 'error'; + $plugin->error_message = $e->getMessage(); + $plugin->save(); + + throw new RuntimeException("Plugin uninstallation failed for [{$name}]: ".$e->getMessage(), 0, $e); + } + + return $plugin; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /** + * @throws InvalidArgumentException + */ + private function resolvePlugin(string $name): Plugin + { + $plugin = Plugin::where('name', $name)->first(); + + if ($plugin === null) { + throw new InvalidArgumentException("Plugin [{$name}] not found. Run discover() first."); + } + + return $plugin; + } + + /** + * Resolve the PluginServiceProvider for a Plugin model. + * + * @throws RuntimeException + */ + private function resolveProvider(Plugin $plugin): PluginServiceProvider + { + /** @var array $manifest */ + $manifest = $plugin->manifest; + $class = (string) ($manifest['provider_class'] ?? ''); + + if ($class === '' || ! class_exists($class)) { + throw new RuntimeException( + "Provider class [{$class}] for plugin [{$plugin->name}] not found." + ); + } + + if (! is_a($class, PluginServiceProvider::class, true)) { + throw new RuntimeException( + "Provider [{$class}] must extend ".PluginServiceProvider::class.'.' + ); + } + + /** @var PluginServiceProvider */ + return app($class); + } +} diff --git a/database/migrations/2026_03_15_300001_create_plugins_table.php b/database/migrations/2026_03_15_300001_create_plugins_table.php new file mode 100644 index 0000000..c4fa5db --- /dev/null +++ b/database/migrations/2026_03_15_300001_create_plugins_table.php @@ -0,0 +1,35 @@ +string('id', 26)->primary(); + $table->string('name', 255)->unique(); + $table->string('display_name', 255); + $table->string('version', 50); + $table->text('description')->nullable(); + $table->json('manifest'); + $table->string('status', 20)->default('discovered'); + $table->timestamp('installed_at')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('status'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('plugins'); + } +}; diff --git a/database/migrations/2026_03_15_300002_create_plugin_settings_table.php b/database/migrations/2026_03_15_300002_create_plugin_settings_table.php new file mode 100644 index 0000000..9c82c3d --- /dev/null +++ b/database/migrations/2026_03_15_300002_create_plugin_settings_table.php @@ -0,0 +1,32 @@ +string('id', 26)->primary(); + $table->string('plugin_id', 26); + $table->string('space_id', 26)->nullable(); + $table->string('key', 255); + $table->json('value'); + $table->boolean('is_secret')->default(false); + $table->timestamps(); + + $table->unique(['plugin_id', 'space_id', 'key']); + $table->index('plugin_id'); + $table->index('space_id'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('plugin_settings'); + } +}; From d036dd6d3b5a6b8bd8c5478856cbd84a6ac4cea1 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:15:42 +0000 Subject: [PATCH 04/17] feat(chat): ConversationService, IntentRouter, and permission guard --- app/Services/Chat/ConversationService.php | 237 ++++++++++++++++ app/Services/Chat/IntentPermissionGuard.php | 68 +++++ app/Services/Chat/IntentRouter.php | 282 ++++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 app/Services/Chat/ConversationService.php create mode 100644 app/Services/Chat/IntentPermissionGuard.php create mode 100644 app/Services/Chat/IntentRouter.php diff --git a/app/Services/Chat/ConversationService.php b/app/Services/Chat/ConversationService.php new file mode 100644 index 0000000..799b4c6 --- /dev/null +++ b/app/Services/Chat/ConversationService.php @@ -0,0 +1,237 @@ +> + */ + public function handle( + User $user, + Space $space, + string $conversationId, + string $message, + ): Generator { + // 1. Load conversation + last 15 messages for context + $conversation = ChatConversation::where('id', $conversationId) + ->where('space_id', $space->id) + ->where('user_id', $user->id) + ->firstOrFail(); + + $history = $conversation->messages() + ->orderByDesc('created_at') + ->limit(15) + ->get() + ->reverse() + ->values(); + + // 2. Build messages array for LLM (conversation history) + $llmMessages = $history->map(fn (ChatMessage $msg) => [ + 'role' => $msg->role, + 'content' => $msg->content, + ])->values()->all(); + + // Append the new user message + $llmMessages[] = ['role' => 'user', 'content' => $message]; + + // 3. Save user message to DB + $conversation->messages()->create([ + 'role' => 'user', + 'content' => $message, + ]); + + // 4. Build system prompt + $systemPrompt = $this->buildSystemPrompt($user, $space); + + // 5. Call LLM + try { + $response = $this->llmManager->complete([ + 'model' => config('numen.chat.model', 'claude-haiku-4-5-20251001'), + 'system' => $systemPrompt, + 'messages' => $llmMessages, + 'max_tokens' => 1024, + 'temperature' => 0.4, + '_purpose' => 'cms_chat', + ]); + } catch (\Throwable $e) { + Log::error('ConversationService: LLM call failed', [ + 'conversation_id' => $conversationId, + 'error' => $e->getMessage(), + ]); + throw $e; + } + + // 6. Parse LLM response (JSON with message + intent) + $raw = $response->content; + $parsed = $this->parseResponse($raw); + + $humanMessage = $parsed['message'] ?? $raw; + $intent = $parsed['intent'] ?? null; + + // 7. Yield text chunk + yield ['type' => 'text', 'content' => $humanMessage]; + + // 8. Yield intent chunk if present + if ($intent !== null && isset($intent['action'])) { + yield ['type' => 'intent', 'intent' => $intent]; + } + + // 9. Save assistant message to DB + $conversation->messages()->create([ + 'role' => 'assistant', + 'content' => $humanMessage, + 'intent' => $intent, + 'input_tokens' => $response->inputTokens, + 'output_tokens' => $response->outputTokens, + 'cost_usd' => $response->costUsd, + ]); + + // 10. Update conversation last_active_at + $conversation->update(['last_active_at' => now()]); + + // 11. Track cost + $this->costTracker->recordUsage($response->costUsd, $space->id); + + // 12. Yield done chunk + yield ['type' => 'done', 'cost_usd' => $response->costUsd]; + } + + /** + * Build the CMS assistant system prompt with available actions based on user permissions. + */ + private function buildSystemPrompt(User $user, Space $space): string + { + $actions = $this->buildAvailableActions($user, $space); + $actionsJson = json_encode($actions, JSON_PRETTY_PRINT); + $spaceName = $space->name; + $userName = $user->name; + + return << + */ + private function buildAvailableActions(User $user, Space $space): array + { + $actions = ['query.generic' => 'Answer general questions about content in the space']; + + if ($user->isAdmin() || $user->hasPermission('content.view', $space->id)) { + $actions['content.query'] = 'Query and list content items with filters'; + } + + if ($user->isAdmin() || $user->hasPermission('content.create', $space->id)) { + $actions['content.create'] = 'Create new content or a content brief'; + } + + if ($user->isAdmin() || $user->hasPermission('content.update', $space->id)) { + $actions['content.update'] = 'Update existing content fields'; + } + + if ($user->isAdmin() || $user->hasPermission('content.delete', $space->id)) { + $actions['content.delete'] = 'Delete content (requires confirmation)'; + } + + if ($user->isAdmin() || $user->hasPermission('content.publish', $space->id)) { + $actions['content.publish'] = 'Publish content (requires confirmation)'; + $actions['content.unpublish'] = 'Unpublish / archive content (requires confirmation)'; + } + + if ($user->isAdmin() || $user->hasPermission('pipeline.trigger', $space->id)) { + $actions['pipeline.trigger'] = 'Trigger a content pipeline run'; + } + + return $actions; + } + + /** + * Parse LLM response — expects JSON but falls back gracefully. + * + * @return array + */ + private function parseResponse(string $raw): array + { + // Strip markdown code fences if present + $cleaned = (string) preg_replace('/^```(?:json)?\s*/m', '', $raw); + $cleaned = (string) preg_replace('/\s*```$/m', '', $cleaned); + $cleaned = trim($cleaned); + + $decoded = json_decode($cleaned, true); + + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + + // Fallback: return raw as message with no intent + Log::warning('ConversationService: LLM response was not valid JSON', [ + 'raw_length' => strlen($raw), + ]); + + return ['message' => $raw, 'intent' => null]; + } +} diff --git a/app/Services/Chat/IntentPermissionGuard.php b/app/Services/Chat/IntentPermissionGuard.php new file mode 100644 index 0000000..8403e27 --- /dev/null +++ b/app/Services/Chat/IntentPermissionGuard.php @@ -0,0 +1,68 @@ + + */ + private const ACTION_PERMISSIONS = [ + 'content.query' => 'content.view', + 'content.create' => 'content.create', + 'content.update' => 'content.update', + 'content.delete' => 'content.delete', + 'content.publish' => 'content.publish', + 'content.unpublish' => 'content.publish', + 'pipeline.trigger' => 'pipeline.trigger', + 'query.generic' => '', + ]; + + /** + * Check if the user is permitted to execute the given intent in the given space. + * + * @param array $intent + * + * @throws PermissionDeniedException + */ + public function check(array $intent, User $user, Space $space): bool + { + $action = $intent['action'] ?? 'query.generic'; + + // Admins bypass all permission checks + if ($user->isAdmin()) { + return true; + } + + // query.generic requires no special permission + if ($action === 'query.generic') { + return true; + } + + $permission = self::ACTION_PERMISSIONS[$action] ?? null; + + // Unknown action — deny by default + if ($permission === null) { + throw new PermissionDeniedException("chat.{$action}"); + } + + if (! $user->hasPermission($permission, $space->id)) { + throw new PermissionDeniedException($permission); + } + + return true; + } +} diff --git a/app/Services/Chat/IntentRouter.php b/app/Services/Chat/IntentRouter.php new file mode 100644 index 0000000..d43ab40 --- /dev/null +++ b/app/Services/Chat/IntentRouter.php @@ -0,0 +1,282 @@ + $intent + * @return array{success: bool, result: mixed, message: string} + */ + public function route(array $intent, User $user, Space $space): array + { + $action = $intent['action'] ?? 'query.generic'; + $params = $intent['params'] ?? []; + + try { + return match ($action) { + 'content.query' => $this->handleContentQuery($params, $space), + 'content.create' => $this->handleContentCreate($params, $user, $space), + 'content.update' => $this->handleContentUpdate($params, $space), + 'content.delete' => $this->handleContentDelete($params, $space), + 'content.publish' => $this->handleContentStatusChange($params, $space, 'published'), + 'content.unpublish' => $this->handleContentStatusChange($params, $space, 'draft'), + 'pipeline.trigger' => $this->handlePipelineTrigger($params, $user, $space), + 'query.generic' => ['success' => true, 'result' => null, 'message' => 'No action required.'], + default => [ + 'success' => false, + 'result' => null, + 'message' => "Unknown action: {$action}", + ], + }; + } catch (\Throwable $e) { + Log::error('IntentRouter: action failed', [ + 'action' => $action, + 'space_id' => $space->id, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'result' => null, + 'message' => 'Action failed: '.$e->getMessage(), + ]; + } + } + + /** + * @param array $params + * @return array{success: bool, result: mixed, message: string} + */ + private function handleContentQuery(array $params, Space $space): array + { + $query = Content::query() + ->where('space_id', $space->id) + ->with(['currentVersion', 'contentType']); + + if (isset($params['status'])) { + $query->where('status', $params['status']); + } + + if (isset($params['type'])) { + $query->whereHas('contentType', fn ($q) => $q->where('slug', $params['type'])); + } + + if (isset($params['locale'])) { + $query->where('locale', $params['locale']); + } + + if (isset($params['search'])) { + $query->whereHas('currentVersion', fn ($q) => $q->where('title', 'like', '%'.$params['search'].'%')); + } + + $limit = min((int) ($params['limit'] ?? 10), 50); + $items = $query->orderByDesc('updated_at')->limit($limit)->get(); + + $result = $items->map(fn (Content $c) => [ + 'id' => $c->id, + 'slug' => $c->slug, + 'title' => $c->currentVersion !== null ? $c->currentVersion->title ?? 'Untitled' : 'Untitled', + 'status' => $c->status, + 'locale' => $c->locale, + 'type' => $c->contentType?->slug, + 'updated_at' => $c->updated_at->diffForHumans(), + ])->values()->all(); + + return [ + 'success' => true, + 'result' => $result, + 'message' => count($result).' content item(s) found.', + ]; + } + + /** + * @param array $params + * @return array{success: bool, result: mixed, message: string} + */ + private function handleContentCreate(array $params, User $user, Space $space): array + { + $title = (string) ($params['title'] ?? 'New Content'); + $description = (string) ($params['description'] ?? ''); + $contentTypeSlug = (string) ($params['type'] ?? 'article'); + $locale = (string) ($params['locale'] ?? config('app.locale', 'en')); + + $brief = ContentBrief::create([ + 'space_id' => $space->id, + 'title' => $title, + 'description' => $description, + 'content_type_slug' => $contentTypeSlug, + 'target_locale' => $locale, + 'source' => 'chat', + 'status' => 'pending', + 'priority' => 'normal', + ]); + + $pipeline = ContentPipeline::where('space_id', $space->id) + ->where('is_active', true) + ->first(); + + if ($pipeline) { + $this->pipelineExecutor->start($brief, $pipeline); + + return [ + 'success' => true, + 'result' => ['brief_id' => $brief->id], + 'message' => "Content brief created and pipeline triggered. Brief ID: {$brief->id}", + ]; + } + + return [ + 'success' => true, + 'result' => ['brief_id' => $brief->id], + 'message' => "Content brief created (no active pipeline found). Brief ID: {$brief->id}", + ]; + } + + /** + * @param array $params + * @return array{success: bool, result: mixed, message: string} + */ + private function handleContentUpdate(array $params, Space $space): array + { + $contentId = (string) ($params['content_id'] ?? ''); + + if ($contentId === '') { + return ['success' => false, 'result' => null, 'message' => 'content_id is required for update.']; + } + + $content = Content::where('space_id', $space->id)->findOrFail($contentId); + + $allowed = ['slug', 'locale', 'metadata']; + $updates = array_intersect_key($params, array_flip($allowed)); + + if (! empty($updates)) { + $content->update($updates); + } + + return [ + 'success' => true, + 'result' => ['content_id' => $content->id], + 'message' => "Content {$content->id} updated.", + ]; + } + + /** + * @param array $params + * @return array{success: bool, result: mixed, message: string} + */ + private function handleContentDelete(array $params, Space $space): array + { + $contentId = (string) ($params['content_id'] ?? ''); + + if ($contentId === '') { + return ['success' => false, 'result' => null, 'message' => 'content_id is required for delete.']; + } + + $content = Content::where('space_id', $space->id)->findOrFail($contentId); + + foreach ($content->pipelineRuns as $run) { + $run->generationLogs()->delete(); + $run->versions()->update(['pipeline_run_id' => null]); + $run->delete(); + } + + foreach ($content->versions as $version) { + $version->blocks()->delete(); + } + + $content->versions()->delete(); + $content->mediaAssets()->detach(); + $content->update(['hero_image_id' => null]); + $content->delete(); + + return [ + 'success' => true, + 'result' => ['deleted_id' => $contentId], + 'message' => "Content {$contentId} has been permanently deleted.", + ]; + } + + /** + * @param array $params + * @return array{success: bool, result: mixed, message: string} + */ + private function handleContentStatusChange(array $params, Space $space, string $status): array + { + $contentId = (string) ($params['content_id'] ?? ''); + + if ($contentId === '') { + return ['success' => false, 'result' => null, 'message' => 'content_id is required.']; + } + + $content = Content::where('space_id', $space->id)->findOrFail($contentId); + + $updates = ['status' => $status]; + if ($status === 'published' && ! $content->published_at) { + $updates['published_at'] = now(); + } + + $content->update($updates); + + $label = $status === 'published' ? 'published' : 'unpublished'; + + return [ + 'success' => true, + 'result' => ['content_id' => $content->id, 'status' => $status], + 'message' => "Content {$content->id} has been {$label}.", + ]; + } + + /** + * @param array $params + * @return array{success: bool, result: mixed, message: string} + */ + private function handlePipelineTrigger(array $params, User $user, Space $space): array + { + $briefId = (string) ($params['brief_id'] ?? ''); + + if ($briefId === '') { + return ['success' => false, 'result' => null, 'message' => 'brief_id is required to trigger a pipeline.']; + } + + $brief = ContentBrief::where('space_id', $space->id)->findOrFail($briefId); + + $pipeline = ContentPipeline::where('space_id', $space->id) + ->where('is_active', true) + ->first(); + + if (! $pipeline) { + return ['success' => false, 'result' => null, 'message' => 'No active pipeline found in this space.']; + } + + $run = $this->pipelineExecutor->start($brief, $pipeline); + + return [ + 'success' => true, + 'result' => ['run_id' => $run->id], + 'message' => "Pipeline triggered. Run ID: {$run->id}", + ]; + } +} From 58786b16a5c19c647d71c7563feacfc2aaac4edd Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:19:08 +0000 Subject: [PATCH 05/17] feat(chat): API controller with SSE streaming + routes --- app/Http/Controllers/Api/ChatController.php | 176 +++++++++++++++++++ app/Http/Requests/SendChatMessageRequest.php | 23 +++ routes/api.php | 12 ++ 3 files changed, 211 insertions(+) create mode 100644 app/Http/Controllers/Api/ChatController.php create mode 100644 app/Http/Requests/SendChatMessageRequest.php diff --git a/app/Http/Controllers/Api/ChatController.php b/app/Http/Controllers/Api/ChatController.php new file mode 100644 index 0000000..56f4004 --- /dev/null +++ b/app/Http/Controllers/Api/ChatController.php @@ -0,0 +1,176 @@ +user()->id) + ->orderByDesc('last_active_at') + ->paginate(20); + + return response()->json(['data' => $conversations]); + } + + /** + * Create a new conversation. + */ + public function createConversation(Request $request): JsonResponse + { + $data = $request->validate([ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + 'title' => ['nullable', 'string', 'max:255'], + ]); + + /** @var \App\Models\User $user */ + $user = $request->user(); + + $conversation = ChatConversation::create([ + 'space_id' => $data['space_id'], + 'user_id' => $user->id, + 'title' => $data['title'] ?? null, + 'last_active_at' => now(), + ]); + + return response()->json(['data' => $conversation], 201); + } + + /** + * Delete a conversation. + */ + public function deleteConversation(Request $request, string $id): JsonResponse + { + /** @var \App\Models\User $user */ + $user = $request->user(); + + $conversation = ChatConversation::where('id', $id) + ->where('user_id', $user->id) + ->firstOrFail(); + + $conversation->delete(); + + return response()->json(null, 204); + } + + /** + * Get message history for a conversation (paginated). + */ + public function messages(Request $request, string $id): JsonResponse + { + /** @var \App\Models\User $user */ + $user = $request->user(); + + $conversation = ChatConversation::where('id', $id) + ->where('user_id', $user->id) + ->firstOrFail(); + + $messages = $conversation->messages() + ->orderBy('created_at') + ->paginate(50); + + return response()->json(['data' => $messages]); + } + + /** + * Send a message and stream the assistant's response via SSE. + */ + public function sendMessage(SendChatMessageRequest $request, string $id): StreamedResponse + { + /** @var \App\Models\User $user */ + $user = $request->user(); + + $conversation = ChatConversation::where('id', $id) + ->where('user_id', $user->id) + ->firstOrFail(); + + $space = $conversation->space; + $message = $request->validated('message'); + + $generator = $this->conversationService->handle( + user: $user, + space: $space, + conversationId: $conversation->id, + message: $message, + ); + + return response()->stream(function () use ($generator): void { + foreach ($generator as $chunk) { + echo 'data: '.json_encode($chunk)."\n\n"; + ob_flush(); + flush(); + } + echo "data: [DONE]\n\n"; + ob_flush(); + flush(); + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + ]); + } + + /** + * Execute the pending action for a conversation. + */ + public function confirmAction(Request $request, string $id): JsonResponse + { + /** @var \App\Models\User $user */ + $user = $request->user(); + + $conversation = ChatConversation::where('id', $id) + ->where('user_id', $user->id) + ->firstOrFail(); + + if (! $conversation->pending_action) { + return response()->json(['error' => 'No pending action'], 422); + } + + $pendingAction = $conversation->pending_action; + + $conversation->update(['pending_action' => null]); + + return response()->json([ + 'data' => [ + 'confirmed' => true, + 'action' => $pendingAction, + ], + ]); + } + + /** + * Cancel the pending action for a conversation. + */ + public function cancelAction(Request $request, string $id): JsonResponse + { + /** @var \App\Models\User $user */ + $user = $request->user(); + + $conversation = ChatConversation::where('id', $id) + ->where('user_id', $user->id) + ->firstOrFail(); + + $conversation->update(['pending_action' => null]); + + return response()->json([ + 'data' => [ + 'cancelled' => true, + ], + ]); + } +} diff --git a/app/Http/Requests/SendChatMessageRequest.php b/app/Http/Requests/SendChatMessageRequest.php new file mode 100644 index 0000000..75158cd --- /dev/null +++ b/app/Http/Requests/SendChatMessageRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'message' => ['required', 'string', 'max:2000'], + ]; + } +} diff --git a/routes/api.php b/routes/api.php index c40ff0f..ba4c08e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\AuditLogController; use App\Http\Controllers\Api\BriefController; +use App\Http\Controllers\Api\ChatController; use App\Http\Controllers\Api\ComponentDefinitionController; use App\Http\Controllers\Api\ContentController; use App\Http\Controllers\Api\ContentTaxonomyController; @@ -321,3 +322,14 @@ Route::delete('/format-templates/{template}', [FormatTemplateController::class, 'destroy']); }); }); + +// Chat / Conversational CMS API +Route::prefix('v1/chat')->middleware(['auth:sanctum', 'throttle:20,1'])->group(function () { + Route::get('/conversations', [ChatController::class, 'conversations']); + Route::post('/conversations', [ChatController::class, 'createConversation']); + Route::delete('/conversations/{id}', [ChatController::class, 'deleteConversation']); + Route::get('/conversations/{id}/messages', [ChatController::class, 'messages']); + Route::post('/conversations/{id}/messages', [ChatController::class, 'sendMessage']); + Route::post('/conversations/{id}/confirm', [ChatController::class, 'confirmAction']); + Route::delete('/conversations/{id}/confirm', [ChatController::class, 'cancelAction']); +}); From 6803867a40746a693d2e52a9058bdc4c15f5c382 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:22:35 +0000 Subject: [PATCH 06/17] feat(chat): context manager with summarization + cost tracking --- app/Services/Chat/ChatCostTracker.php | 72 +++++++++ .../Chat/ConversationContextManager.php | 140 ++++++++++++++++++ app/Services/Chat/ConversationService.php | 25 ++-- 3 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 app/Services/Chat/ChatCostTracker.php create mode 100644 app/Services/Chat/ConversationContextManager.php diff --git a/app/Services/Chat/ChatCostTracker.php b/app/Services/Chat/ChatCostTracker.php new file mode 100644 index 0000000..fc4cfdb --- /dev/null +++ b/app/Services/Chat/ChatCostTracker.php @@ -0,0 +1,72 @@ +update([ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'cost_usd' => $costUsd, + ]); + + $spaceId = $message->conversation->space_id ?? null; + + $this->costTracker->recordUsage($costUsd, $spaceId); + + Log::info('ChatCostTracker: message tracked', [ + 'message_id' => $message->id, + 'conversation_id' => $message->conversation_id, + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'cost_usd' => $costUsd, + 'category' => 'chat', + ]); + } + + /** + * Sum of cost_usd for all messages in a conversation. + */ + public function getConversationCost(string $conversationId): float + { + return (float) ChatMessage::where('conversation_id', $conversationId) + ->sum('cost_usd'); + } + + /** + * Sum of cost_usd for a user's messages today. + * Used for optional per-user rate limiting. + */ + public function getUserDailyCost(int $userId): float + { + return (float) ChatMessage::whereHas( + 'conversation', + fn ($q) => $q->where('user_id', $userId) + ) + ->whereDate('created_at', now()->toDateString()) + ->sum('cost_usd'); + } +} diff --git a/app/Services/Chat/ConversationContextManager.php b/app/Services/Chat/ConversationContextManager.php new file mode 100644 index 0000000..714a116 --- /dev/null +++ b/app/Services/Chat/ConversationContextManager.php @@ -0,0 +1,140 @@ + + */ + public function buildContext(ChatConversation $conversation, int $windowSize = self::WINDOW_SIZE): array + { + /** @var \Illuminate\Database\Eloquent\Collection $messages */ + $messages = $conversation->messages() + ->orderByDesc('created_at') + ->limit($windowSize) + ->get() + ->reverse() + ->values(); + + return $messages->map(fn (ChatMessage $msg) => [ + 'role' => $msg->role, + 'content' => $msg->content, + ])->values()->all(); + } + + /** + * If the conversation exceeds the threshold, summarize older messages and + * store the summary in conversation.context['summary']. + */ + public function summarizeOlder(ChatConversation $conversation): void + { + $totalCount = $conversation->messages()->count(); + + if ($totalCount <= self::SUMMARY_THRESHOLD) { + return; + } + + // Messages older than the window (skip the recent window) + /** @var \Illuminate\Database\Eloquent\Collection $olderMessages */ + $olderMessages = $conversation->messages() + ->orderBy('created_at') + ->limit($totalCount - self::WINDOW_SIZE) + ->get(); + + if ($olderMessages->isEmpty()) { + return; + } + + // Format them for the summarization prompt + $transcript = $olderMessages->map(fn (ChatMessage $msg) => strtoupper($msg->role).': '.$msg->content) + ->join("\n\n"); + + try { + $response = $this->llmManager->complete([ + 'model' => self::SUMMARY_MODEL, + 'system' => 'You are a concise summarizer. Summarize the conversation excerpt into a single compact paragraph that captures key facts, decisions, and user intent. Write in third person.', + 'messages' => [ + ['role' => 'user', 'content' => "Summarize this conversation history:\n\n".$transcript], + ], + 'max_tokens' => 512, + 'temperature' => 0.2, + '_purpose' => 'cms_chat_summarize', + ]); + + $summary = trim($response->content); + $lastMessageId = $olderMessages->last()->id; + + $context = $conversation->context ?? []; + $context['summary'] = $summary; + $context['summary_covers_up_to'] = $lastMessageId; + + $conversation->update(['context' => $context]); + + Log::info('ConversationContextManager: stored summary', [ + 'conversation_id' => $conversation->id, + 'messages_summarized' => $olderMessages->count(), + ]); + } catch (\Throwable $e) { + // Non-fatal: log and continue without summary + Log::warning('ConversationContextManager: summarization failed', [ + 'conversation_id' => $conversation->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Return the full context to send to the LLM: + * [stored summary as synthetic exchange (if any)] + [recent window messages] + * + * @return array + */ + public function getFullContext(ChatConversation $conversation): array + { + $context = []; + + // Prepend stored summary as a synthetic user+assistant exchange + $stored = $conversation->context ?? []; + if (! empty($stored['summary'])) { + $context[] = [ + 'role' => 'user', + 'content' => '[Earlier conversation summary]', + ]; + $context[] = [ + 'role' => 'assistant', + 'content' => $stored['summary'], + ]; + } + + // Append recent window + $window = $this->buildContext($conversation, self::WINDOW_SIZE); + + return array_merge($context, $window); + } +} diff --git a/app/Services/Chat/ConversationService.php b/app/Services/Chat/ConversationService.php index 799b4c6..3dc932d 100644 --- a/app/Services/Chat/ConversationService.php +++ b/app/Services/Chat/ConversationService.php @@ -36,6 +36,7 @@ class ConversationService public function __construct( private readonly LLMManager $llmManager, private readonly CostTracker $costTracker, + private readonly ConversationContextManager $contextManager, ) {} /** @@ -49,24 +50,14 @@ public function handle( string $conversationId, string $message, ): Generator { - // 1. Load conversation + last 15 messages for context + // 1. Load conversation $conversation = ChatConversation::where('id', $conversationId) ->where('space_id', $space->id) ->where('user_id', $user->id) ->firstOrFail(); - $history = $conversation->messages() - ->orderByDesc('created_at') - ->limit(15) - ->get() - ->reverse() - ->values(); - - // 2. Build messages array for LLM (conversation history) - $llmMessages = $history->map(fn (ChatMessage $msg) => [ - 'role' => $msg->role, - 'content' => $msg->content, - ])->values()->all(); + // 2. Build full context via context manager (summary + window) + $llmMessages = $this->contextManager->getFullContext($conversation); // Append the new user message $llmMessages[] = ['role' => 'user', 'content' => $message]; @@ -114,7 +105,8 @@ public function handle( } // 9. Save assistant message to DB - $conversation->messages()->create([ + /** @var ChatMessage $assistantMessage */ + $assistantMessage = $conversation->messages()->create([ 'role' => 'assistant', 'content' => $humanMessage, 'intent' => $intent, @@ -129,7 +121,10 @@ public function handle( // 11. Track cost $this->costTracker->recordUsage($response->costUsd, $space->id); - // 12. Yield done chunk + // 12. Trigger summarization if conversation is long enough + $this->contextManager->summarizeOlder($conversation); + + // 13. Yield done chunk yield ['type' => 'done', 'cost_usd' => $response->costUsd]; } From 30dbfc5947cca150fd5e6cb79da5b0f71cc22776 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:23:51 +0000 Subject: [PATCH 07/17] feat(plugins): pipeline stage integration + LLM provider registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PipelineStageContract interface (type/label/configSchema/handle) - Add LLMProviderContract interface (name/generateText/generateChat/supportsStreaming) - Add PluginStageJob: queued job resolving handler from HookRegistry, calls handle(), advances pipeline via PipelineExecutor::advance() - Update PipelineExecutor: minimal change — check HookRegistry for plugin stage types before core match, extract core logic to dispatchCoreStage() - Update HookRegistry: add registerPipelineStageClass/getPipelineStageHandler/ hasPipelineStageHandler/registerLLMProviderInstance/getLLMProviders methods with type validation against respective contracts - Update LLMManager: add registerProvider() to wire plugin LLM providers via anonymous adapter class implementing LLMProvider contract - Update AppServiceProvider: after PluginLoader boots, iterate HookRegistry::getLLMProviders() and wire each into LLMManager --- app/Jobs/PluginStageJob.php | 92 +++++++++++ app/Pipelines/PipelineExecutor.php | 26 +++ app/Plugin/Contracts/LLMProviderContract.php | 40 +++++ .../Contracts/PipelineStageContract.php | 53 ++++++ app/Plugin/HookRegistry.php | 151 +++++++++--------- app/Providers/AppServiceProvider.php | 7 + app/Services/AI/LLMManager.php | 50 ++++++ 7 files changed, 345 insertions(+), 74 deletions(-) create mode 100644 app/Jobs/PluginStageJob.php create mode 100644 app/Plugin/Contracts/LLMProviderContract.php create mode 100644 app/Plugin/Contracts/PipelineStageContract.php diff --git a/app/Jobs/PluginStageJob.php b/app/Jobs/PluginStageJob.php new file mode 100644 index 0000000..3dd51d2 --- /dev/null +++ b/app/Jobs/PluginStageJob.php @@ -0,0 +1,92 @@ +onQueue(config('numen.queues.plugins', 'default')); + } + + public function backoff(): array + { + return [15, 60, 180]; + } + + public function handle(HookRegistry $registry, PipelineExecutor $executor): void + { + $stageType = $this->stage['type']; + + Log::info('Running plugin pipeline stage', [ + 'run_id' => $this->run->id, + 'stage' => $this->stage['name'] ?? $stageType, + 'type' => $stageType, + ]); + + $handlerClass = $registry->getPipelineStageHandler($stageType); + + if ($handlerClass === null) { + $message = "No handler registered for plugin stage type [{$stageType}]."; + Log::error($message, ['run_id' => $this->run->id]); + $this->run->markFailed($message); + + return; + } + + try { + /** @var PipelineStageContract $handler */ + $handler = app($handlerClass); + + $stageConfig = $this->stage['config'] ?? []; + $result = $handler->handle($this->run, $stageConfig); + + $result = array_merge(['success' => true, 'stage' => $this->stage['name'] ?? $stageType], $result); + + $executor->advance($this->run, $result); + + } catch (\Throwable $e) { + Log::error('Plugin stage exception', [ + 'run_id' => $this->run->id, + 'stage' => $this->stage['name'] ?? $stageType, + 'error' => $e->getMessage(), + ]); + + if ($this->attempts() >= $this->tries) { + $this->run->markFailed( + "Plugin stage [{$stageType}] failed after {$this->tries} attempts: {$e->getMessage()}" + ); + } + + throw $e; + } + } +} diff --git a/app/Pipelines/PipelineExecutor.php b/app/Pipelines/PipelineExecutor.php index 99b1266..27e33a5 100644 --- a/app/Pipelines/PipelineExecutor.php +++ b/app/Pipelines/PipelineExecutor.php @@ -6,12 +6,14 @@ use App\Events\Pipeline\PipelineStageCompleted; use App\Events\Pipeline\PipelineStarted; use App\Jobs\GenerateImage; +use App\Jobs\PluginStageJob; use App\Jobs\PublishContent; use App\Jobs\RunAgentStage; use App\Models\Content; use App\Models\ContentBrief; use App\Models\ContentPipeline; use App\Models\PipelineRun; +use App\Plugin\HookRegistry; use Illuminate\Support\Facades\Log; class PipelineExecutor @@ -93,6 +95,23 @@ public function advance(PipelineRun $run, array $stageResult): void * Dispatch the appropriate job for a pipeline stage. */ private function dispatchStage(PipelineRun $run, array $stage): void + { + // Check plugin-registered stage types first + $registry = app(HookRegistry::class); + if ($registry->hasPipelineStageHandler($stage['type'])) { + PluginStageJob::dispatch($run, $stage); + + return; + } + + // Core stage types + $this->dispatchCoreStage($run, $stage); + } + + /** + * Dispatch core (built-in) pipeline stage jobs. + */ + private function dispatchCoreStage(PipelineRun $run, array $stage): void { $queue = match ($stage['type']) { 'ai_generate' => config('numen.queues.generation'), @@ -124,6 +143,13 @@ private function dispatchStage(PipelineRun $run, array $stage): void return; } + if (! in_array($stage['type'], ['ai_generate', 'ai_transform', 'ai_review'], true)) { + Log::warning('Unknown pipeline stage type, falling back to RunAgentStage', [ + 'run_id' => $run->id, + 'type' => $stage['type'], + ]); + } + RunAgentStage::dispatch($run, $stage)->onQueue($queue); } } diff --git a/app/Plugin/Contracts/LLMProviderContract.php b/app/Plugin/Contracts/LLMProviderContract.php new file mode 100644 index 0000000..84f2c00 --- /dev/null +++ b/app/Plugin/Contracts/LLMProviderContract.php @@ -0,0 +1,40 @@ + $options Provider-specific options (model, temperature, …) + */ + public function generateText(string $prompt, array $options = []): string; + + /** + * Generate a chat completion for a list of messages. + * + * Each message is an associative array with at least 'role' and 'content' keys. + * + * @param array> $messages + * @param array $options + */ + public function generateChat(array $messages, array $options = []): string; + + /** + * Whether this provider supports streaming responses. + */ + public function supportsStreaming(): bool; +} diff --git a/app/Plugin/Contracts/PipelineStageContract.php b/app/Plugin/Contracts/PipelineStageContract.php new file mode 100644 index 0000000..80d5a8c --- /dev/null +++ b/app/Plugin/Contracts/PipelineStageContract.php @@ -0,0 +1,53 @@ + ['type' => 'number', 'default' => 0.8], + * 'sources' => ['type' => 'array', 'items' => ['type' => 'string']], + * ] + * + * @return array + */ + public static function configSchema(): array; + + /** + * Execute the stage logic. + * + * Receives the current PipelineRun (with full context) and the stage's + * configuration array from the pipeline definition. + * + * Must return a result array that will be passed to PipelineExecutor::advance(). + * At minimum return ['success' => true]. + * + * @param array $stageConfig + * @return array + */ + public function handle(PipelineRun $run, array $stageConfig): array; +} diff --git a/app/Plugin/HookRegistry.php b/app/Plugin/HookRegistry.php index 44cec22..5798e25 100644 --- a/app/Plugin/HookRegistry.php +++ b/app/Plugin/HookRegistry.php @@ -2,22 +2,26 @@ namespace App\Plugin; +use App\Plugin\Contracts\LLMProviderContract; +use App\Plugin\Contracts\PipelineStageContract; use Closure; +use InvalidArgumentException; -/** - * Central hook bus for the Numen plugin system. - * - * Plugins register their extensions here; the core calls them at the - * appropriate extension points. - */ +/** Central hook bus for the Numen plugin system. */ class HookRegistry { /** @var array> */ private array $pipelineStages = []; + /** @var array> */ + private array $pipelineStageClasses = []; + /** @var array */ private array $llmProviders = []; + /** @var array */ + private array $llmProviderInstances = []; + /** @var array */ private array $imageProviders = []; @@ -35,91 +39,121 @@ class HookRegistry // ── Pipeline stages ──────────────────────────────────────────────────────── - /** - * Register a named pipeline stage processor. - * - * The Closure receives (array $context, array $stageConfig): array $context. - */ public function registerPipelineStage(string $stageName, Closure $handler): void { $this->pipelineStages[$stageName][] = $handler; } /** - * Get all handlers registered for a pipeline stage name. + * Register a class-based pipeline stage handler. + * Handler must implement PipelineStageContract. * - * @return array + * @param class-string $handlerClass + * + * @throws InvalidArgumentException */ + public function registerPipelineStageClass(string $stageType, string $handlerClass): void + { + if (! is_a($handlerClass, PipelineStageContract::class, true)) { + throw new InvalidArgumentException( + "Pipeline stage handler [{$handlerClass}] must implement ".PipelineStageContract::class.'.' + ); + } + $this->pipelineStageClasses[$stageType] = $handlerClass; + } + + /** @return array */ public function getPipelineStageHandlers(string $stageName): array { return $this->pipelineStages[$stageName] ?? []; } - /** - * Get all registered pipeline stage names. - * - * @return array - */ + /** @return class-string|null */ + public function getPipelineStageHandler(string $stageType): ?string + { + return $this->pipelineStageClasses[$stageType] ?? null; + } + + public function hasPipelineStageHandler(string $stageType): bool + { + return isset($this->pipelineStageClasses[$stageType]); + } + + /** @return array */ public function getRegisteredPipelineStages(): array { return array_keys($this->pipelineStages); } + /** @return array */ + public function getRegisteredPipelineStageTypes(): array + { + return array_keys($this->pipelineStageClasses); + } + // ── LLM providers ────────────────────────────────────────────────────────── - /** - * Register a custom LLM provider factory. - * - * The Closure receives (array $config): LLMProviderInterface. - */ public function registerLLMProvider(string $providerName, Closure $factory): void { $this->llmProviders[$providerName] = $factory; } /** - * Get the factory for an LLM provider, or null if not registered. + * Register an LLMProviderContract instance directly. + * Used by AppServiceProvider after PluginLoader boots. + * + * @throws InvalidArgumentException */ + public function registerLLMProviderInstance(string $providerName, mixed $provider): void + { + if (! ($provider instanceof LLMProviderContract)) { + throw new InvalidArgumentException( + "LLM provider [{$providerName}] must implement ".LLMProviderContract::class.'.' + ); + } + $this->llmProviderInstances[$providerName] = $provider; + } + public function getLLMProviderFactory(string $providerName): ?Closure { return $this->llmProviders[$providerName] ?? null; } - /** - * Get all registered LLM provider names. - * - * @return array - */ + public function getLLMProviderInstance(string $providerName): ?LLMProviderContract + { + return $this->llmProviderInstances[$providerName] ?? null; + } + + /** @return array */ public function getRegisteredLLMProviders(): array { return array_keys($this->llmProviders); } - // ── Image providers ──────────────────────────────────────────────────────── - /** - * Register a custom image generation provider factory. + * Get all registered LLMProviderContract instances. + * Used by AppServiceProvider to wire plugin LLM providers into LLMManager. * - * The Closure receives (array $config): ImageProviderInterface. + * @return array */ + public function getLLMProviders(): array + { + return $this->llmProviderInstances; + } + + // ── Image providers ──────────────────────────────────────────────────────── + public function registerImageProvider(string $providerName, Closure $factory): void { $this->imageProviders[$providerName] = $factory; } - /** - * Get the factory for an image provider, or null if not registered. - */ public function getImageProviderFactory(string $providerName): ?Closure { return $this->imageProviders[$providerName] ?? null; } - /** - * Get all registered image provider names. - * - * @return array - */ + /** @return array */ public function getRegisteredImageProviders(): array { return array_keys($this->imageProviders); @@ -127,30 +161,17 @@ public function getRegisteredImageProviders(): array // ── Content events ───────────────────────────────────────────────────────── - /** - * Listen to a content lifecycle event. - * - * $eventName examples: 'content.created', 'content.published', 'content.deleted' - * The Closure receives (mixed $payload): void. - */ public function onContentEvent(string $eventName, Closure $listener): void { $this->contentEventListeners[$eventName][] = $listener; } - /** - * Get all listeners for a content event. - * - * @return array - */ + /** @return array */ public function getContentEventListeners(string $eventName): array { return $this->contentEventListeners[$eventName] ?? []; } - /** - * Dispatch a content event to all registered listeners. - */ public function dispatchContentEvent(string $eventName, mixed $payload): void { foreach ($this->getContentEventListeners($eventName) as $listener) { @@ -161,8 +182,6 @@ public function dispatchContentEvent(string $eventName, mixed $payload): void // ── Admin menu items ─────────────────────────────────────────────────────── /** - * Add an item to the admin navigation menu. - * * @param array{id: string, label: string, route: string, icon?: string|null, weight?: int} $item */ public function addAdminMenuItem(array $item): void @@ -177,8 +196,6 @@ public function addAdminMenuItem(array $item): void } /** - * Get all registered admin menu items, sorted by weight. - * * @return array */ public function getAdminMenuItems(): array @@ -192,8 +209,6 @@ public function getAdminMenuItems(): array // ── Admin widgets ────────────────────────────────────────────────────────── /** - * Add a Vue widget to the admin dashboard. - * * @param array{id: string, component: string, props?: array} $widget */ public function addAdminWidget(array $widget): void @@ -206,8 +221,6 @@ public function addAdminWidget(array $widget): void } /** - * Get all registered admin dashboard widgets. - * * @return array}> */ public function getAdminWidgets(): array @@ -217,22 +230,12 @@ public function getAdminWidgets(): array // ── Vue components ───────────────────────────────────────────────────────── - /** - * Register a Vue component that should be globally available in the frontend. - * - * @param string $name Vue component name (e.g. 'MyPluginWidget') - * @param string $importPath Absolute path or NPM package reference to the .vue file - */ public function registerVueComponent(string $name, string $importPath): void { $this->vueComponents[$name] = $importPath; } - /** - * Get all registered Vue component definitions. - * - * @return array - */ + /** @return array */ public function getVueComponents(): array { return $this->vueComponents; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5d102d2..bb80712 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -120,6 +120,13 @@ public function boot(): void // Boot plugin system $this->app->make(PluginLoader::class)->boot(); + // Wire plugin-registered LLM providers into LLMManager + $hookRegistry = $this->app->make(HookRegistry::class); + $llmManager = $this->app->make(LLMManager::class); + foreach ($hookRegistry->getLLMProviders() as $name => $provider) { + $llmManager->registerProvider($name, $provider); + } + // Register search event listeners Event::listen(ContentPublished::class, IndexContentForSearch::class); Event::listen(ContentUnpublished::class, RemoveFromSearchIndex::class); diff --git a/app/Services/AI/LLMManager.php b/app/Services/AI/LLMManager.php index 4071b8f..5c60eac 100644 --- a/app/Services/AI/LLMManager.php +++ b/app/Services/AI/LLMManager.php @@ -3,6 +3,7 @@ namespace App\Services\AI; use App\Models\Persona; +use App\Plugin\Contracts\LLMProviderContract; use App\Services\AI\Contracts\LLMProvider; use App\Services\AI\Exceptions\AllProvidersFailedException; use App\Services\AI\Exceptions\CostLimitExceededException; @@ -266,4 +267,53 @@ private function resolveChain(string $primaryProvider, string $primaryModel, ?st return [$provider, $equivalent]; })->all(); } + + /** + * Register a plugin-provided LLM provider into the routing table. + * + * Called by AppServiceProvider after PluginLoader boots, for each provider + * registered via HookRegistry::registerLLMProviderInstance(). + * + * The $provider is wrapped in an adapter closure so it integrates with + * the existing LLMProvider (Contracts\LLMProvider) interface expected by + * ::complete(). For plugin providers we use a lightweight proxy that + * delegates complete() calls to LLMProviderContract::generateChat(). + */ + public function registerProvider(string $name, LLMProviderContract $provider): void + { + $this->providers[$name] = new class($provider) implements LLMProvider + { + public function __construct(private readonly LLMProviderContract $inner) {} + + public function complete(array $params): \App\Services\AI\LLMResponse + { + $messages = $params['messages'] ?? []; + if (! empty($params['system'])) { + array_unshift($messages, ['role' => 'system', 'content' => $params['system']]); + } + $text = $this->inner->generateChat($messages, $params); + + return new \App\Services\AI\LLMResponse( + content: $text, + model: $params['model'] ?? $this->inner->name(), + provider: $this->inner->name(), + inputTokens: 0, + outputTokens: 0, + latencyMs: 0, + stopReason: 'stop', + costUsd: 0.0, + ); + } + + public function isAvailable(string $model): bool + { + return true; + } + + public function getName(): string + { + return $this->inner->name(); + } + }; + } } From e72b26decf040cf1a4e94e1e5cdaaf4155cd0b56 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 16:26:42 +0000 Subject: [PATCH 08/17] feat(chat): Vue chat sidebar with message streaming and action cards - ChatSidebar: fixed right panel (w-96, z-50), toggle button, localStorage persistence - ChatConversationList: collapsible list, new chat button, conversation switching - ChatMessageList: auto-scroll, loading states, empty state - ChatMessage: user (indigo-600) / assistant (gray-800) styling, markdown via marked - ChatInputBar: auto-resize textarea, Enter to send, streaming disabled state, cost indicator - ConfirmActionCard: inline confirm/cancel with POST/DELETE /api/v1/chat/conversations/{id}/confirm - ActionResultCard: completed action display with optional link - SSE streaming via fetch + ReadableStream, handles text/confirm/action/cost chunk types - MainLayout: ChatSidebar component integrated --- .../js/Components/Chat/ActionResultCard.vue | 24 ++ .../Components/Chat/ChatConversationList.vue | 86 ++++++ resources/js/Components/Chat/ChatInputBar.vue | 83 ++++++ resources/js/Components/Chat/ChatMessage.vue | 94 ++++++ .../js/Components/Chat/ChatMessageList.vue | 65 +++++ resources/js/Components/Chat/ChatSidebar.vue | 268 ++++++++++++++++++ .../js/Components/Chat/ConfirmActionCard.vue | 66 +++++ resources/js/Layouts/MainLayout.vue | 8 +- 8 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 resources/js/Components/Chat/ActionResultCard.vue create mode 100644 resources/js/Components/Chat/ChatConversationList.vue create mode 100644 resources/js/Components/Chat/ChatInputBar.vue create mode 100644 resources/js/Components/Chat/ChatMessage.vue create mode 100644 resources/js/Components/Chat/ChatMessageList.vue create mode 100644 resources/js/Components/Chat/ChatSidebar.vue create mode 100644 resources/js/Components/Chat/ConfirmActionCard.vue diff --git a/resources/js/Components/Chat/ActionResultCard.vue b/resources/js/Components/Chat/ActionResultCard.vue new file mode 100644 index 0000000..3499958 --- /dev/null +++ b/resources/js/Components/Chat/ActionResultCard.vue @@ -0,0 +1,24 @@ + + + diff --git a/resources/js/Components/Chat/ChatConversationList.vue b/resources/js/Components/Chat/ChatConversationList.vue new file mode 100644 index 0000000..8af7adf --- /dev/null +++ b/resources/js/Components/Chat/ChatConversationList.vue @@ -0,0 +1,86 @@ + + + diff --git a/resources/js/Components/Chat/ChatInputBar.vue b/resources/js/Components/Chat/ChatInputBar.vue new file mode 100644 index 0000000..299cd17 --- /dev/null +++ b/resources/js/Components/Chat/ChatInputBar.vue @@ -0,0 +1,83 @@ + + +