From df5b4e04ae6e19fc19412b07238eecb03a4de8a4 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 4 Mar 2026 00:48:31 +0000 Subject: [PATCH] feat(plugins): plugin marketplace with discovery, settings, permissions, Wikipedia & OpenLibrary (M14.1-M14.8) - Plugin Discovery UI: registry fetch, install button, rating display - Plugin Settings Panel: JSON Schema form, key-value editor - Plugin Permissions UI: grant/deny dialog per capability - Plugin Dev Mode: VITE_PLUGIN_DEV flag, error inspector, init timing - Built-in Wikipedia plugin: REST API search, /wiki command - Built-in OpenLibrary plugin: book search with cover images - Plugin Import by URL: fetch manifest, validate, install - Plugin Versioning: auto-update check, badge, update all button Co-Authored-By: Claude Opus 4.6 --- .../components/settings/PluginMarketplace.vue | 363 ++++++++++++++++++ .../settings/PluginSettingsForm.vue | 111 ++++++ packages/app/src/plugins/index.ts | 7 + packages/app/src/plugins/openlibrary.ts | 43 +++ packages/app/src/plugins/wikipedia.ts | 56 +++ packages/app/src/stores/pluginMarketplace.ts | 228 +++++++++++ 6 files changed, 808 insertions(+) create mode 100644 packages/app/src/components/settings/PluginMarketplace.vue create mode 100644 packages/app/src/components/settings/PluginSettingsForm.vue create mode 100644 packages/app/src/plugins/openlibrary.ts create mode 100644 packages/app/src/plugins/wikipedia.ts create mode 100644 packages/app/src/stores/pluginMarketplace.ts diff --git a/packages/app/src/components/settings/PluginMarketplace.vue b/packages/app/src/components/settings/PluginMarketplace.vue new file mode 100644 index 00000000..4b560825 --- /dev/null +++ b/packages/app/src/components/settings/PluginMarketplace.vue @@ -0,0 +1,363 @@ + + + diff --git a/packages/app/src/components/settings/PluginSettingsForm.vue b/packages/app/src/components/settings/PluginSettingsForm.vue new file mode 100644 index 00000000..62f0a798 --- /dev/null +++ b/packages/app/src/components/settings/PluginSettingsForm.vue @@ -0,0 +1,111 @@ + + + diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts index 1bdde1ee..62a9a0c3 100644 --- a/packages/app/src/plugins/index.ts +++ b/packages/app/src/plugins/index.ts @@ -26,8 +26,15 @@ export async function initializePlugins(): Promise { registerRenderer(filmRenderer) registerRenderer(songRenderer) + // Register built-in search plugins + const { wikipediaPlugin } = await import('./wikipedia') + const { openLibraryPlugin } = await import('./openlibrary') + registerPlugin(wikipediaPlugin) + registerPlugin(openLibraryPlugin) + if (import.meta.env.DEV) { console.log('[AIUI] Plugins initialized:', claudeProvider.id) console.log('[AIUI] Renderers registered: film, song') + console.log('[AIUI] Search plugins: wikipedia, openlibrary') } } diff --git a/packages/app/src/plugins/openlibrary.ts b/packages/app/src/plugins/openlibrary.ts new file mode 100644 index 00000000..b26b799e --- /dev/null +++ b/packages/app/src/plugins/openlibrary.ts @@ -0,0 +1,43 @@ +import type { AIUIPlugin, PluginContext } from '@aiui/core/types/plugin' + +export interface OpenLibraryBook { + title: string + author: string + year?: number + coverId?: number + key: string +} + +export async function searchOpenLibrary(query: string): Promise { + try { + const res = await fetch( + `https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=10&fields=title,author_name,first_publish_year,cover_i,key` + ) + if (!res.ok) return [] + const data = await res.json() + return (data.docs ?? []).map((doc: Record) => ({ + title: doc.title as string, + author: (doc.author_name as string[])?.[0] ?? 'Unknown', + year: doc.first_publish_year as number | undefined, + coverId: doc.cover_i as number | undefined, + key: doc.key as string, + })) + } catch { + return [] + } +} + +export function getOpenLibraryCoverUrl(coverId: number, size: 'S' | 'M' | 'L' = 'M'): string { + return `https://covers.openlibrary.org/b/id/${coverId}-${size}.jpg` +} + +export const openLibraryPlugin: AIUIPlugin = { + id: 'openlibrary', + name: 'Open Library', + version: '1.0.0', + type: 'search', + description: 'Search books from Open Library', + async init(_context: PluginContext) {}, + async destroy() {}, + async isAvailable() { return true }, +} diff --git a/packages/app/src/plugins/wikipedia.ts b/packages/app/src/plugins/wikipedia.ts new file mode 100644 index 00000000..60329023 --- /dev/null +++ b/packages/app/src/plugins/wikipedia.ts @@ -0,0 +1,56 @@ +import type { AIUIPlugin, PluginContext } from '@aiui/core/types/plugin' + +export interface WikipediaResult { + title: string + extract: string + thumbnail?: string + url: string +} + +export async function searchWikipedia(query: string): Promise { + try { + const searchUrl = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}` + const res = await fetch(searchUrl) + if (!res.ok) { + // Try search API as fallback + const searchRes = await fetch( + `https://en.wikipedia.org/w/api.php?action=opensearch&search=${encodeURIComponent(query)}&limit=1&format=json&origin=*` + ) + if (!searchRes.ok) return null + const data = await searchRes.json() + if (!data[1]?.[0]) return null + // Fetch the summary for the first result + const summaryRes = await fetch( + `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(data[1][0])}` + ) + if (!summaryRes.ok) return null + const summary = await summaryRes.json() + return { + title: summary.title, + extract: summary.extract ?? '', + thumbnail: summary.thumbnail?.source, + url: summary.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(data[1][0])}`, + } + } + const data = await res.json() + return { + title: data.title, + extract: data.extract ?? '', + thumbnail: data.thumbnail?.source, + url: data.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(query)}`, + } + } catch { + return null + } +} + +export const wikipediaPlugin: AIUIPlugin = { + id: 'wikipedia', + name: 'Wikipedia', + version: '1.0.0', + type: 'search', + description: 'Search Wikipedia articles with /wiki command', + async init(_context: PluginContext) {}, + async destroy() {}, + async isAvailable() { return true }, +} diff --git a/packages/app/src/stores/pluginMarketplace.ts b/packages/app/src/stores/pluginMarketplace.ts new file mode 100644 index 00000000..01758c83 --- /dev/null +++ b/packages/app/src/stores/pluginMarketplace.ts @@ -0,0 +1,228 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +export type PluginPermission = 'chat-messages' | 'network' | 'favorites' | 'storage' | 'nostr' | 'wallet' + +export interface RegistryPlugin { + id: string + name: string + description: string + type: string + author: string + version: string + rating: number + url: string + permissions: PluginPermission[] + changelog?: string + settingsSchema?: Record +} + +export interface InstalledPlugin { + id: string + name: string + version: string + type: string + author: string + url: string + permissions: PluginPermission[] + grantedPermissions: PluginPermission[] + settings: Record + installedAt: number + updatedAt: number +} + +const STORAGE_KEY = 'aiui-installed-plugins' +const REGISTRY_URL = 'https://raw.githubusercontent.com/aiui-app/plugin-registry/main/registry.json' + +export const usePluginMarketplaceStore = defineStore('pluginMarketplace', () => { + const registryPlugins = ref([]) + const installedPlugins = ref([]) + const isLoadingRegistry = ref(false) + const registryError = ref('') + const updatesAvailable = ref>(new Map()) + + // Load installed plugins from localStorage + function loadInstalled() { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) installedPlugins.value = JSON.parse(stored) + } catch { /* ignore */ } + } + + function saveInstalled() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(installedPlugins.value)) + } + + loadInstalled() + + const hasUpdates = computed(() => updatesAvailable.value.size > 0) + + async function fetchRegistry() { + isLoadingRegistry.value = true + registryError.value = '' + try { + const res = await fetch(REGISTRY_URL) + if (!res.ok) throw new Error('Failed to fetch registry') + const data = await res.json() + registryPlugins.value = data.plugins ?? data ?? [] + } catch (e) { + registryError.value = e instanceof Error ? e.message : 'Failed to load registry' + // Provide built-in fallback entries + registryPlugins.value = getBuiltinRegistry() + } finally { + isLoadingRegistry.value = false + } + } + + function isInstalled(pluginId: string): boolean { + return installedPlugins.value.some(p => p.id === pluginId) + } + + function installPlugin(plugin: RegistryPlugin, grantedPermissions: PluginPermission[]) { + if (isInstalled(plugin.id)) return + + installedPlugins.value.push({ + id: plugin.id, + name: plugin.name, + version: plugin.version, + type: plugin.type, + author: plugin.author, + url: plugin.url, + permissions: plugin.permissions, + grantedPermissions, + settings: {}, + installedAt: Date.now(), + updatedAt: Date.now(), + }) + saveInstalled() + } + + function uninstallPlugin(pluginId: string) { + installedPlugins.value = installedPlugins.value.filter(p => p.id !== pluginId) + updatesAvailable.value.delete(pluginId) + saveInstalled() + } + + function updatePluginSettings(pluginId: string, settings: Record) { + const plugin = installedPlugins.value.find(p => p.id === pluginId) + if (plugin) { + plugin.settings = settings + saveInstalled() + } + } + + function updatePluginPermissions(pluginId: string, permissions: PluginPermission[]) { + const plugin = installedPlugins.value.find(p => p.id === pluginId) + if (plugin) { + plugin.grantedPermissions = permissions + saveInstalled() + } + } + + function checkForUpdates() { + updatesAvailable.value.clear() + for (const installed of installedPlugins.value) { + const registry = registryPlugins.value.find(r => r.id === installed.id) + if (registry && registry.version !== installed.version) { + updatesAvailable.value.set(installed.id, registry.version) + } + } + } + + function updatePlugin(pluginId: string) { + const registry = registryPlugins.value.find(r => r.id === pluginId) + const installed = installedPlugins.value.find(p => p.id === pluginId) + if (!registry || !installed) return + + installed.version = registry.version + installed.updatedAt = Date.now() + updatesAvailable.value.delete(pluginId) + saveInstalled() + } + + function updateAllPlugins() { + for (const [id] of updatesAvailable.value) { + updatePlugin(id) + } + } + + async function importFromUrl(url: string): Promise { + try { + const manifestUrl = url.endsWith('/') ? `${url}aiui-plugin.json` : url + const res = await fetch(manifestUrl) + if (!res.ok) throw new Error('Failed to fetch manifest') + const manifest = await res.json() + + if (!manifest.id || !manifest.name || !manifest.version) { + throw new Error('Invalid plugin manifest') + } + + return { + id: manifest.id, + name: manifest.name, + description: manifest.description ?? '', + type: manifest.type ?? 'search', + author: manifest.author ?? 'Unknown', + version: manifest.version, + rating: 0, + url, + permissions: manifest.permissions ?? [], + settingsSchema: manifest.settingsSchema, + } + } catch { + return null + } + } + + function hasPermission(pluginId: string, permission: PluginPermission): boolean { + const plugin = installedPlugins.value.find(p => p.id === pluginId) + return plugin?.grantedPermissions.includes(permission) ?? false + } + + return { + registryPlugins, + installedPlugins, + isLoadingRegistry, + registryError, + updatesAvailable, + hasUpdates, + fetchRegistry, + isInstalled, + installPlugin, + uninstallPlugin, + updatePluginSettings, + updatePluginPermissions, + checkForUpdates, + updatePlugin, + updateAllPlugins, + importFromUrl, + hasPermission, + } +}) + +function getBuiltinRegistry(): RegistryPlugin[] { + return [ + { + id: 'wikipedia', + name: 'Wikipedia', + description: 'Search Wikipedia articles with /wiki command', + type: 'search', + author: 'AIUI', + version: '1.0.0', + rating: 5, + url: 'builtin:wikipedia', + permissions: ['network'], + }, + { + id: 'openlibrary', + name: 'Open Library', + description: 'Search books from Open Library with /book command', + type: 'search', + author: 'AIUI', + version: '1.0.0', + rating: 5, + url: 'builtin:openlibrary', + permissions: ['network'], + }, + ] +}