feat(plugin-sdk,stage-tamagotchi): demo plugin, and plugin inspector
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
||||
# Devtools Sample Plugin
|
||||
|
||||
This sample plugin is for validating plugin host behavior in the **Plugin Host Inspector** page.
|
||||
|
||||
## Files
|
||||
|
||||
- `devtools-sample-plugin.json`: plugin manifest (`ManifestV1`)
|
||||
- `devtools-sample-plugin.mjs`: plugin implementation
|
||||
|
||||
## How to use
|
||||
|
||||
1. Open `/devtools/plugin-host` in Stage Tamagotchi.
|
||||
2. Note the `registry.root` path from the page.
|
||||
3. Copy both files into that `registry.root` directory.
|
||||
4. In Plugin Host Inspector:
|
||||
- click `Refresh`
|
||||
- find `devtools-sample-plugin`
|
||||
- click `Enable`
|
||||
- click `Load` (or `Load Enabled`)
|
||||
5. Confirm:
|
||||
- plugin appears as `loaded`
|
||||
- session phase becomes `ready`
|
||||
- capability list is visible
|
||||
|
||||
## What this plugin does
|
||||
|
||||
- `init`: logs startup in renderer/main console.
|
||||
- `setupModules`: calls `apis.providers.listProviders()` and logs provider names.
|
||||
|
||||
It does not mutate app state; it is safe for lifecycle verification.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "manifest.plugin.airi.moeru.ai",
|
||||
"name": "devtools-sample-plugin",
|
||||
"entrypoints": {
|
||||
"electron": "./devtools-sample-plugin.mjs"
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Example plugin for verifying plugin-host lifecycle in devtools.
|
||||
*
|
||||
* This module intentionally avoids external package imports so it can run
|
||||
* from the userData plugins folder without additional dependency setup.
|
||||
*/
|
||||
export async function init(_context) {
|
||||
console.info('[devtools-sample-plugin] init', { at: nowIso() })
|
||||
}
|
||||
|
||||
export async function setupModules({ apis }) {
|
||||
const providers = await apis.providers.listProviders()
|
||||
console.info('[devtools-sample-plugin] setupModules', {
|
||||
at: nowIso(),
|
||||
providerCount: providers.length,
|
||||
providerNames: providers.map(provider => provider.name),
|
||||
})
|
||||
}
|
||||
@@ -64,6 +64,19 @@ async function writeManifest(params: { dir: string, name: string, entrypoint: st
|
||||
return path
|
||||
}
|
||||
|
||||
async function writeManifestInPluginDir(params: { rootDir: string, pluginDirName: string, pluginName: string, entrypointPath: string }) {
|
||||
const pluginDir = join(params.rootDir, params.pluginDirName)
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
const entrypointFile = await copyEntrypoint({ dir: pluginDir, path: params.entrypointPath })
|
||||
const manifestPath = await writeManifest({
|
||||
dir: pluginDir,
|
||||
name: params.pluginName,
|
||||
entrypoint: `./${entrypointFile}`,
|
||||
})
|
||||
|
||||
return { pluginDir, manifestPath }
|
||||
}
|
||||
|
||||
async function copyEntrypoint(params: { dir: string, path: string }) {
|
||||
const file = basename(params.path)
|
||||
const destination = join(params.dir, file)
|
||||
@@ -89,14 +102,22 @@ describe('setupPluginHost', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists manifests from the plugins directory', async () => {
|
||||
it('lists manifests from plugin subdirectories', async () => {
|
||||
const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts')
|
||||
const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts')
|
||||
|
||||
const normalFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint })
|
||||
const errorFile = await copyEntrypoint({ dir: pluginsDir, path: errorEntrypoint })
|
||||
const normalPath = await writeManifest({ dir: pluginsDir, name: 'test-normal', entrypoint: normalFile })
|
||||
const errorPath = await writeManifest({ dir: pluginsDir, name: 'test-error', entrypoint: errorFile })
|
||||
const { manifestPath: normalPath } = await writeManifestInPluginDir({
|
||||
rootDir: pluginsDir,
|
||||
pluginDirName: 'test-normal',
|
||||
pluginName: 'test-normal',
|
||||
entrypointPath: normalEntrypoint,
|
||||
})
|
||||
const { manifestPath: errorPath } = await writeManifestInPluginDir({
|
||||
rootDir: pluginsDir,
|
||||
pluginDirName: 'test-error',
|
||||
pluginName: 'test-error',
|
||||
entrypointPath: errorEntrypoint,
|
||||
})
|
||||
|
||||
await setupPluginHost()
|
||||
|
||||
@@ -112,14 +133,55 @@ describe('setupPluginHost', () => {
|
||||
]))
|
||||
})
|
||||
|
||||
it('ignores root-level manifests and only loads manifests from subdirectories', async () => {
|
||||
const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts')
|
||||
|
||||
const { manifestPath } = await writeManifestInPluginDir({
|
||||
rootDir: pluginsDir,
|
||||
pluginDirName: 'devtools-sample-plugin',
|
||||
pluginName: 'devtools-sample-plugin',
|
||||
entrypointPath: normalEntrypoint,
|
||||
})
|
||||
const rootEntrypointFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint })
|
||||
await writeManifest({
|
||||
dir: pluginsDir,
|
||||
name: 'root-level-plugin',
|
||||
entrypoint: rootEntrypointFile,
|
||||
})
|
||||
|
||||
await setupPluginHost()
|
||||
|
||||
expect(contextState.lastContext).toBeDefined()
|
||||
const invokeList = defineInvoke(contextState.lastContext!, electronPluginList)
|
||||
const snapshot = await invokeList()
|
||||
|
||||
expect(snapshot.plugins).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'devtools-sample-plugin',
|
||||
path: manifestPath,
|
||||
enabled: false,
|
||||
loaded: false,
|
||||
isNew: true,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('loads enabled plugins and keeps failed plugins unloaded', async () => {
|
||||
const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts')
|
||||
const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts')
|
||||
|
||||
const normalFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint })
|
||||
const errorFile = await copyEntrypoint({ dir: pluginsDir, path: errorEntrypoint })
|
||||
await writeManifest({ dir: pluginsDir, name: 'test-normal', entrypoint: normalFile })
|
||||
await writeManifest({ dir: pluginsDir, name: 'test-error', entrypoint: errorFile })
|
||||
await writeManifestInPluginDir({
|
||||
rootDir: pluginsDir,
|
||||
pluginDirName: 'test-normal',
|
||||
pluginName: 'test-normal',
|
||||
entrypointPath: normalEntrypoint,
|
||||
})
|
||||
await writeManifestInPluginDir({
|
||||
rootDir: pluginsDir,
|
||||
pluginDirName: 'test-error',
|
||||
pluginName: 'test-error',
|
||||
entrypointPath: errorEntrypoint,
|
||||
})
|
||||
|
||||
await setupPluginHost()
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { PluginManifestSummary, PluginRegistrySnapshot } from '../../../../shared/eventa'
|
||||
import type {
|
||||
PluginHostDebugSnapshot,
|
||||
PluginManifestSummary,
|
||||
PluginRegistrySnapshot,
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
import { mkdir, readdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join } from 'node:path'
|
||||
@@ -13,9 +17,12 @@ import { app, ipcMain } from 'electron'
|
||||
import { array, object, record, safeParse, string } from 'valibot'
|
||||
|
||||
import {
|
||||
electronPluginInspect,
|
||||
electronPluginList,
|
||||
electronPluginLoad,
|
||||
electronPluginLoadEnabled,
|
||||
electronPluginSetEnabled,
|
||||
electronPluginUnload,
|
||||
electronPluginUpdateCapability,
|
||||
pluginProtocolListProviders,
|
||||
pluginProtocolListProvidersEventName,
|
||||
@@ -69,15 +76,25 @@ async function loadManifestsFrom(dir: string, log: ReturnType<typeof useLogg>):
|
||||
await mkdir(dir, { recursive: true })
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const manifests: ManifestEntry[] = []
|
||||
const manifestPaths: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile())
|
||||
if (!entry.isDirectory())
|
||||
continue
|
||||
|
||||
if (extname(entry.name) !== '.json')
|
||||
continue
|
||||
const pluginDir = join(dir, entry.name)
|
||||
const pluginEntries = await readdir(pluginDir, { withFileTypes: true })
|
||||
for (const pluginEntry of pluginEntries) {
|
||||
if (!pluginEntry.isFile())
|
||||
continue
|
||||
if (extname(pluginEntry.name) !== '.json')
|
||||
continue
|
||||
|
||||
const path = join(dir, entry.name)
|
||||
manifestPaths.push(join(pluginDir, pluginEntry.name))
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of manifestPaths) {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
@@ -85,6 +102,7 @@ async function loadManifestsFrom(dir: string, log: ReturnType<typeof useLogg>):
|
||||
log.warn('invalid plugin manifest schema', { path })
|
||||
continue
|
||||
}
|
||||
|
||||
manifests.push({ manifest: parsed, path })
|
||||
}
|
||||
catch (error) {
|
||||
@@ -145,6 +163,7 @@ export async function setupPluginHost(): Promise<PluginHostService> {
|
||||
let entries = await loadManifestsFrom(pluginsRoot, log)
|
||||
let manifests = entries.map(entry => entry.manifest)
|
||||
const loaded = new Set<string>()
|
||||
const loadedSessionIds = new Map<string, string>()
|
||||
|
||||
const refreshManifests = async () => {
|
||||
entries = await loadManifestsFrom(pluginsRoot, log)
|
||||
@@ -163,6 +182,53 @@ export async function setupPluginHost(): Promise<PluginHostService> {
|
||||
}
|
||||
}
|
||||
|
||||
const toDebugSnapshot = (): PluginHostDebugSnapshot => {
|
||||
return {
|
||||
registry: toSnapshot(),
|
||||
sessions: host.listSessions().map(session => ({
|
||||
id: session.id,
|
||||
manifestName: session.manifest.name,
|
||||
phase: session.phase,
|
||||
runtime: session.runtime,
|
||||
moduleId: session.identity.id,
|
||||
})),
|
||||
capabilities: capabilityHost.listCapabilities(),
|
||||
refreshedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
const findManifestEntry = (name: string) => {
|
||||
return entries.find(entry => entry.manifest.name === name)
|
||||
}
|
||||
|
||||
const loadPluginByName = async (name: string) => {
|
||||
if (loaded.has(name))
|
||||
return
|
||||
|
||||
const entry = findManifestEntry(name)
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin manifest not found: ${name}`)
|
||||
}
|
||||
|
||||
const session = await host.start(entry.manifest, { cwd: dirname(entry.path) })
|
||||
loaded.add(name)
|
||||
loadedSessionIds.set(name, session.id)
|
||||
log.log('plugin loaded', { plugin: name, sessionId: session.id })
|
||||
}
|
||||
|
||||
const unloadPluginByName = (name: string) => {
|
||||
const sessionId = loadedSessionIds.get(name)
|
||||
if (!sessionId) {
|
||||
loaded.delete(name)
|
||||
return
|
||||
}
|
||||
|
||||
host.stop(sessionId)
|
||||
loadedSessionIds.delete(name)
|
||||
loaded.delete(name)
|
||||
log.log('plugin unloaded', { plugin: name, sessionId })
|
||||
}
|
||||
|
||||
const loadEnabled = async () => {
|
||||
const config = getConfig()
|
||||
for (const entry of entries) {
|
||||
@@ -173,9 +239,7 @@ export async function setupPluginHost(): Promise<PluginHostService> {
|
||||
continue
|
||||
|
||||
try {
|
||||
await host.start(entry.manifest, { cwd: dirname(entry.path) })
|
||||
loaded.add(name)
|
||||
log.log('plugin loaded', { plugin: name })
|
||||
await loadPluginByName(name)
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).withFields({ plugin: name }).error('plugin failed to start')
|
||||
@@ -224,6 +288,22 @@ export async function setupPluginHost(): Promise<PluginHostService> {
|
||||
return toSnapshot()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginLoad, async (payload) => {
|
||||
await refreshManifests()
|
||||
await loadPluginByName(payload.name)
|
||||
return toSnapshot()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginUnload, async (payload) => {
|
||||
unloadPluginByName(payload.name)
|
||||
return toSnapshot()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginInspect, async () => {
|
||||
await refreshManifests()
|
||||
return toDebugSnapshot()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => {
|
||||
if (payload.key === pluginProtocolListProvidersEventName && payload.state === 'ready') {
|
||||
capabilityHost.setProvidersListResolver(async () => await invokePluginProtocolListProviders())
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { themeColorFromValue, useThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
|
||||
import { ToasterRoot } from '@proj-airi/stage-ui/components'
|
||||
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
|
||||
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
|
||||
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
||||
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
|
||||
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||
@@ -24,6 +25,12 @@ import ResizeHandler from './components/ResizeHandler.vue'
|
||||
|
||||
import {
|
||||
electronOpenSettings,
|
||||
electronPluginInspect,
|
||||
electronPluginList,
|
||||
electronPluginLoad,
|
||||
electronPluginLoadEnabled,
|
||||
electronPluginSetEnabled,
|
||||
electronPluginUnload,
|
||||
electronPluginUpdateCapability,
|
||||
electronStartTrackMousePosition,
|
||||
electronStartWebSocketServer,
|
||||
@@ -46,6 +53,7 @@ const chatSessionStore = useChatSessionStore()
|
||||
const serverChannelStore = useModsServerChannelStore()
|
||||
const characterOrchestratorStore = useCharacterOrchestratorStore()
|
||||
const analyticsStore = useSharedAnalyticsStore()
|
||||
const pluginHostInspectorStore = usePluginHostInspectorStore()
|
||||
usePerfTracerBridgeStore()
|
||||
|
||||
watch(language, () => {
|
||||
@@ -60,6 +68,24 @@ onMounted(() => updateThemeColor())
|
||||
const startWebSocketServer = useElectronEventaInvoke(electronStartWebSocketServer)
|
||||
|
||||
onMounted(async () => {
|
||||
const context = useElectronEventaContext()
|
||||
const listPlugins = useElectronEventaInvoke(electronPluginList)
|
||||
const setPluginEnabled = useElectronEventaInvoke(electronPluginSetEnabled)
|
||||
const loadEnabledPlugins = useElectronEventaInvoke(electronPluginLoadEnabled)
|
||||
const loadPlugin = useElectronEventaInvoke(electronPluginLoad)
|
||||
const unloadPlugin = useElectronEventaInvoke(electronPluginUnload)
|
||||
const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
|
||||
|
||||
// NOTICE: register plugin host bridge before long async startup work so devtools pages can use it immediately.
|
||||
pluginHostInspectorStore.setBridge({
|
||||
list: () => listPlugins(),
|
||||
setEnabled: payload => setPluginEnabled(payload),
|
||||
loadEnabled: () => loadEnabledPlugins(),
|
||||
load: payload => loadPlugin(payload),
|
||||
unload: payload => unloadPlugin(payload),
|
||||
inspect: () => inspectPluginHost(),
|
||||
})
|
||||
|
||||
analyticsStore.initialize()
|
||||
cardStore.initialize()
|
||||
onboardingStore.initializeSetupCheck()
|
||||
@@ -73,9 +99,8 @@ onMounted(async () => {
|
||||
await contextBridgeStore.initialize()
|
||||
characterOrchestratorStore.initialize()
|
||||
|
||||
const context = useElectronEventaContext()
|
||||
const startTrackingCursorPoint = defineInvoke(context.value, electronStartTrackMousePosition)
|
||||
const reportPluginCapability = defineInvoke(context.value, electronPluginUpdateCapability)
|
||||
const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition)
|
||||
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
|
||||
await startTrackingCursorPoint()
|
||||
|
||||
// Expose stage provider definitions to plugin host APIs.
|
||||
|
||||
@@ -65,6 +65,12 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:transfer-horizontal-bold-duotone',
|
||||
to: '/devtools/websocket-inspector',
|
||||
},
|
||||
{
|
||||
title: 'Plugin Host Debug',
|
||||
description: 'Inspect discovered/enabled/loaded plugins and control load/unload lifecycle',
|
||||
icon: 'i-solar:bug-bold-duotone',
|
||||
to: '/devtools/plugin-host',
|
||||
},
|
||||
{
|
||||
title: 'Screen Capture',
|
||||
description: 'Capture screen or window as video and/or audio streams',
|
||||
|
||||
@@ -12,6 +12,9 @@ export const electronRestartWebSocketServer = defineInvokeEventa<void, { websock
|
||||
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
|
||||
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
|
||||
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')
|
||||
export const electronPluginLoad = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:load')
|
||||
export const electronPluginUnload = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:unload')
|
||||
export const electronPluginInspect = defineInvokeEventa<PluginHostDebugSnapshot>('eventa:invoke:electron:plugins:inspect')
|
||||
export const electronPluginUpdateCapability = defineInvokeEventa<PluginCapabilityState, PluginCapabilityPayload>('eventa:invoke:electron:plugins:capability:update')
|
||||
export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
|
||||
export const pluginProtocolListProviders = defineInvokeEventa<Array<{ name: string }>>(pluginProtocolListProvidersEventName)
|
||||
@@ -93,6 +96,21 @@ export interface PluginCapabilityState {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface PluginHostSessionSummary {
|
||||
id: string
|
||||
manifestName: string
|
||||
phase: string
|
||||
runtime: 'electron' | 'node' | 'web'
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
export interface PluginHostDebugSnapshot {
|
||||
registry: PluginRegistrySnapshot
|
||||
sessions: PluginHostSessionSummary[]
|
||||
capabilities: PluginCapabilityState[]
|
||||
refreshedAt: number
|
||||
}
|
||||
|
||||
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
|
||||
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
|
||||
export const widgetsRemove = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:windows:widgets:remove')
|
||||
|
||||
@@ -74,6 +74,12 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:transfer-horizontal-bold-duotone',
|
||||
to: '/devtools/websocket-inspector',
|
||||
},
|
||||
{
|
||||
title: 'Plugin Host Debug',
|
||||
description: 'Inspect plugin host registry and capability state (desktop runtime)',
|
||||
icon: 'i-solar:bug-bold-duotone',
|
||||
to: '/devtools/plugin-host',
|
||||
},
|
||||
{
|
||||
title: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.title'),
|
||||
description: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.description'),
|
||||
|
||||
@@ -7,10 +7,9 @@ export interface CapabilityDescriptor {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const protocolCapabilityWait = defineInvokeEventa<CapabilityDescriptor, {
|
||||
key: string
|
||||
timeoutMs?: number
|
||||
}>('proj-airi:plugin-sdk:apis:protocol:capabilities:wait')
|
||||
export const protocolCapabilityWait = defineInvokeEventa<CapabilityDescriptor, { key: string, timeoutMs?: number }>(
|
||||
'proj-airi:plugin-sdk:apis:protocol:capabilities:wait',
|
||||
)
|
||||
|
||||
export const protocolCapabilitySnapshot = defineInvokeEventa<CapabilityDescriptor[]>(
|
||||
'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot',
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
PluginHostSessionSummary,
|
||||
PluginManifestSummary,
|
||||
} from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
|
||||
|
||||
import { Section } from '@proj-airi/stage-ui/components'
|
||||
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
|
||||
import { Button, Callout, Input } from '@proj-airi/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const store = usePluginHostInspectorStore()
|
||||
const filter = ref('')
|
||||
const selectedPluginName = ref('')
|
||||
|
||||
const discoveredPlugins = computed(() => {
|
||||
const query = filter.value.trim().toLowerCase()
|
||||
const plugins = store.discoveredPlugins.slice().sort((left, right) => left.name.localeCompare(right.name))
|
||||
if (!query)
|
||||
return plugins
|
||||
return plugins.filter(plugin =>
|
||||
plugin.name.toLowerCase().includes(query)
|
||||
|| plugin.path.toLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
|
||||
const enabledPlugins = computed(() => {
|
||||
return discoveredPlugins.value.filter(plugin => plugin.enabled)
|
||||
})
|
||||
|
||||
const loadedPlugins = computed(() => {
|
||||
return discoveredPlugins.value.filter(plugin => plugin.loaded)
|
||||
})
|
||||
|
||||
const sessionByPluginName = computed(() => {
|
||||
const map = new Map<string, PluginHostSessionSummary>()
|
||||
for (const session of store.sessions) {
|
||||
map.set(session.manifestName, session)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const readyCapabilitiesCount = computed(() => {
|
||||
return store.capabilities.filter(capability => capability.state === 'ready').length
|
||||
})
|
||||
|
||||
function chipClasses(theme: 'neutral' | 'emerald' | 'amber') {
|
||||
if (theme === 'emerald') {
|
||||
return [
|
||||
'bg-emerald-100',
|
||||
'text-emerald-700',
|
||||
'dark:bg-emerald-900/50',
|
||||
'dark:text-emerald-300',
|
||||
'border-emerald-300',
|
||||
'dark:border-emerald-700',
|
||||
]
|
||||
}
|
||||
|
||||
if (theme === 'amber') {
|
||||
return [
|
||||
'bg-amber-100',
|
||||
'text-amber-700',
|
||||
'dark:bg-amber-900/50',
|
||||
'dark:text-amber-300',
|
||||
'border-amber-300',
|
||||
'dark:border-amber-700',
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
'bg-neutral-100',
|
||||
'text-neutral-700',
|
||||
'dark:bg-neutral-800',
|
||||
'dark:text-neutral-300',
|
||||
'border-neutral-300',
|
||||
'dark:border-neutral-700',
|
||||
]
|
||||
}
|
||||
|
||||
function phaseChipTheme(phase: string) {
|
||||
if (phase === 'ready')
|
||||
return 'emerald'
|
||||
if (phase === 'failed')
|
||||
return 'amber'
|
||||
if (phase === 'loading' || phase === 'authenticating' || phase === 'preparing')
|
||||
return 'amber'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
await store.refreshAll()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to refresh plugin host debug state.')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnabled() {
|
||||
try {
|
||||
await store.loadEnabled()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to load enabled plugins.')
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(plugin: PluginManifestSummary, enabled: boolean) {
|
||||
try {
|
||||
await store.setEnabled({
|
||||
name: plugin.name,
|
||||
enabled,
|
||||
path: plugin.path,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : `Failed to update enabled state for ${plugin.name}.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlugin(plugin: PluginManifestSummary) {
|
||||
try {
|
||||
await store.load({ name: plugin.name })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : `Failed to load plugin ${plugin.name}.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function unloadPlugin(plugin: PluginManifestSummary) {
|
||||
try {
|
||||
await store.unload({ name: plugin.name })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : `Failed to unload plugin ${plugin.name}.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedPlugin() {
|
||||
const name = selectedPluginName.value.trim()
|
||||
if (!name) {
|
||||
toast.error('Enter a plugin name to load.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await store.load({ name })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : `Failed to load plugin ${name}.`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['h-full', 'flex', 'flex-col', 'gap-4', 'overflow-y-auto', 'p-4']">
|
||||
<Callout
|
||||
v-if="!store.isAvailable"
|
||||
theme="orange"
|
||||
label="Plugin host debug is unavailable in this runtime."
|
||||
description="Open this page from Stage Tamagotchi renderer to use Electron plugin host controls."
|
||||
/>
|
||||
|
||||
<Callout
|
||||
v-if="store.error"
|
||||
theme="orange"
|
||||
label="Last Error"
|
||||
:description="store.error"
|
||||
/>
|
||||
|
||||
<div :class="['grid', 'gap-2', 'sm:grid-cols-2', 'xl:grid-cols-4']">
|
||||
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
|
||||
<div :class="['text-xs', 'uppercase', 'opacity-70']">
|
||||
Discovered
|
||||
</div>
|
||||
<div :class="['text-2xl', 'font-semibold']">
|
||||
{{ store.discoveredPlugins.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
|
||||
<div :class="['text-xs', 'uppercase', 'opacity-70']">
|
||||
Enabled
|
||||
</div>
|
||||
<div :class="['text-2xl', 'font-semibold']">
|
||||
{{ store.enabledPlugins.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
|
||||
<div :class="['text-xs', 'uppercase', 'opacity-70']">
|
||||
Loaded
|
||||
</div>
|
||||
<div :class="['text-2xl', 'font-semibold']">
|
||||
{{ store.loadedPlugins.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['rounded-xl', 'bg-neutral-100', 'p-3', 'dark:bg-neutral-900/70']">
|
||||
<div :class="['text-xs', 'uppercase', 'opacity-70']">
|
||||
Capabilities
|
||||
</div>
|
||||
<div :class="['text-2xl', 'font-semibold']">
|
||||
{{ readyCapabilitiesCount }} / {{ store.capabilities.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<Input
|
||||
v-model="filter"
|
||||
placeholder="Filter discovered plugins..."
|
||||
class="max-w-[440px] min-w-[280px]"
|
||||
/>
|
||||
<Button
|
||||
label="Refresh"
|
||||
icon="i-solar:refresh-bold-duotone"
|
||||
size="sm"
|
||||
:loading="store.loading"
|
||||
@click="refresh"
|
||||
/>
|
||||
<Button
|
||||
label="Load Enabled"
|
||||
icon="i-solar:play-bold-duotone"
|
||||
size="sm"
|
||||
:loading="store.loading"
|
||||
@click="loadEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<Input
|
||||
v-model="selectedPluginName"
|
||||
placeholder="Load discovered plugin by exact name..."
|
||||
class="max-w-[520px] min-w-[320px]"
|
||||
/>
|
||||
<Button
|
||||
label="Load Plugin"
|
||||
icon="i-solar:download-minimalistic-bold-duotone"
|
||||
size="sm"
|
||||
:disabled="!selectedPluginName.trim()"
|
||||
:loading="store.loading"
|
||||
@click="loadSelectedPlugin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Section
|
||||
title="Discovered Plugins"
|
||||
icon="i-solar:list-check-bold-duotone"
|
||||
inner-class="gap-3"
|
||||
>
|
||||
<div
|
||||
v-if="discoveredPlugins.length === 0"
|
||||
:class="['rounded-xl', 'border', 'border-dashed', 'border-neutral-400/50', 'p-4', 'text-sm', 'opacity-70']"
|
||||
>
|
||||
No discovered plugin manifests found.
|
||||
</div>
|
||||
|
||||
<div v-else :class="['grid', 'gap-3']">
|
||||
<div
|
||||
v-for="plugin in discoveredPlugins"
|
||||
:key="plugin.path"
|
||||
:class="['rounded-xl', 'border', 'border-neutral-300', 'bg-white/70', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950/60']"
|
||||
>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'justify-between', 'gap-2']">
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<div :class="['font-semibold']">
|
||||
{{ plugin.name }}
|
||||
</div>
|
||||
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(plugin.enabled ? 'emerald' : 'neutral')]">
|
||||
{{ plugin.enabled ? 'enabled' : 'disabled' }}
|
||||
</span>
|
||||
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(plugin.loaded ? 'emerald' : 'neutral')]">
|
||||
{{ plugin.loaded ? 'loaded' : 'not loaded' }}
|
||||
</span>
|
||||
<span v-if="plugin.isNew" :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses('amber')]">
|
||||
new
|
||||
</span>
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:label="plugin.enabled ? 'Disable' : 'Enable'"
|
||||
:icon="plugin.enabled ? 'i-solar:lock-keyhole-minimalistic-unlocked-bold-duotone' : 'i-solar:lock-keyhole-bold-duotone'"
|
||||
:loading="store.loading"
|
||||
@click="setEnabled(plugin, !plugin.enabled)"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
label="Load"
|
||||
icon="i-solar:play-bold-duotone"
|
||||
:disabled="plugin.loaded"
|
||||
:loading="store.loading"
|
||||
@click="loadPlugin(plugin)"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Unload"
|
||||
icon="i-solar:stop-bold-duotone"
|
||||
:disabled="!plugin.loaded"
|
||||
:loading="store.loading"
|
||||
@click="unloadPlugin(plugin)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="['mt-2', 'text-xs', 'opacity-70', 'font-mono', 'break-all']">
|
||||
{{ plugin.path }}
|
||||
</div>
|
||||
<div :class="['mt-2', 'text-xs', 'opacity-70']">
|
||||
entrypoints: {{ JSON.stringify(plugin.entrypoints) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="sessionByPluginName.get(plugin.name)"
|
||||
:class="['mt-2', 'flex', 'items-center', 'gap-2', 'text-sm']"
|
||||
>
|
||||
<span>phase:</span>
|
||||
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(phaseChipTheme(sessionByPluginName.get(plugin.name)!.phase))]">
|
||||
{{ sessionByPluginName.get(plugin.name)!.phase }}
|
||||
</span>
|
||||
<span :class="['opacity-70', 'font-mono']">{{ sessionByPluginName.get(plugin.name)!.moduleId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Enabled Plugins"
|
||||
icon="i-solar:check-circle-bold-duotone"
|
||||
inner-class="gap-2"
|
||||
>
|
||||
<div :class="['text-sm', 'opacity-80']">
|
||||
{{ enabledPlugins.length }} plugin(s) enabled in registry.
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'gap-2']">
|
||||
<span
|
||||
v-for="plugin in enabledPlugins"
|
||||
:key="`enabled-${plugin.path}`"
|
||||
:class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses('emerald')]"
|
||||
>
|
||||
{{ plugin.name }}
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Loaded Plugins"
|
||||
icon="i-solar:play-circle-bold-duotone"
|
||||
inner-class="gap-2"
|
||||
>
|
||||
<div :class="['text-sm', 'opacity-80']">
|
||||
{{ loadedPlugins.length }} plugin(s) currently loaded in host sessions.
|
||||
</div>
|
||||
<div :class="['grid', 'gap-2']">
|
||||
<div
|
||||
v-for="plugin in loadedPlugins"
|
||||
:key="`loaded-${plugin.path}`"
|
||||
:class="['rounded-lg', 'bg-neutral-100', 'p-2', 'dark:bg-neutral-900/70']"
|
||||
>
|
||||
<div :class="['flex', 'items-center', 'justify-between', 'gap-2']">
|
||||
<span :class="['font-semibold']">{{ plugin.name }}</span>
|
||||
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(phaseChipTheme(sessionByPluginName.get(plugin.name)?.phase ?? 'unknown'))]">
|
||||
{{ sessionByPluginName.get(plugin.name)?.phase ?? 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Capabilities"
|
||||
icon="i-solar:widget-2-bold-duotone"
|
||||
inner-class="gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="store.capabilities.length === 0"
|
||||
:class="['text-sm', 'opacity-70']"
|
||||
>
|
||||
No capabilities announced.
|
||||
</div>
|
||||
<div v-else :class="['grid', 'gap-2']">
|
||||
<div
|
||||
v-for="capability in store.capabilities"
|
||||
:key="capability.key"
|
||||
:class="['rounded-lg', 'border', 'border-neutral-300', 'bg-white/60', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950/60']"
|
||||
>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'justify-between', 'gap-2']">
|
||||
<span :class="['font-mono', 'text-xs', 'sm:text-sm']">{{ capability.key }}</span>
|
||||
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', ...chipClasses(capability.state === 'ready' ? 'emerald' : 'amber')]">
|
||||
{{ capability.state }}
|
||||
</span>
|
||||
</div>
|
||||
<div :class="['mt-2', 'text-xs', 'opacity-70']">
|
||||
updated: {{ new Date(capability.updatedAt).toLocaleString() }}
|
||||
</div>
|
||||
<pre :class="['mt-2', 'overflow-auto', 'rounded-lg', 'bg-neutral-100', 'p-2', 'text-xs', 'dark:bg-neutral-900/70']">{{ JSON.stringify(capability.metadata ?? {}, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
title: Plugin Host Debug
|
||||
subtitleKey: tamagotchi.settings.devtools.title
|
||||
</route>
|
||||
@@ -4,8 +4,9 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCharacterStore } from './character'
|
||||
import { setCharacterLlmMarkerParserFactoryForTest, useCharacterStore } from './character'
|
||||
import { useAiriCardStore } from './modules'
|
||||
import { useSpeechRuntimeStore } from './speech-runtime'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
@@ -17,6 +18,8 @@ const writeLiteralSpy = vi.fn()
|
||||
const writeFlushSpy = vi.fn()
|
||||
const endSpy = vi.fn()
|
||||
const cancelSpy = vi.fn()
|
||||
const parserConsumeSpy = vi.fn()
|
||||
const parserEndSpy = vi.fn()
|
||||
|
||||
const openSpeechIntentSpy = vi.fn(() => ({
|
||||
intentId: 'intent-test',
|
||||
@@ -30,22 +33,32 @@ const openSpeechIntentSpy = vi.fn(() => ({
|
||||
cancel: cancelSpy,
|
||||
}))
|
||||
|
||||
vi.mock('../speech-runtime', () => ({
|
||||
useSpeechRuntimeStore: () => ({
|
||||
openIntent: openSpeechIntentSpy,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('store character', () => {
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
|
||||
setCharacterLlmMarkerParserFactoryForTest(options => ({
|
||||
async consume(textPart: string) {
|
||||
parserConsumeSpy(textPart)
|
||||
if (textPart)
|
||||
await options.onLiteral?.(textPart)
|
||||
},
|
||||
async end() {
|
||||
parserEndSpy()
|
||||
},
|
||||
}))
|
||||
|
||||
writeLiteralSpy.mockClear()
|
||||
writeFlushSpy.mockClear()
|
||||
endSpy.mockClear()
|
||||
cancelSpy.mockClear()
|
||||
openSpeechIntentSpy.mockClear()
|
||||
parserConsumeSpy.mockClear()
|
||||
parserEndSpy.mockClear()
|
||||
|
||||
const speechRuntimeStore = useSpeechRuntimeStore(pinia)
|
||||
speechRuntimeStore.openIntent = openSpeechIntentSpy
|
||||
|
||||
const airiCardStore = useAiriCardStore(pinia)
|
||||
// @ts-expect-error - testing purpose
|
||||
@@ -92,7 +105,7 @@ describe('store character', () => {
|
||||
expect(store.reactions[199]?.message).toBe('message-200')
|
||||
})
|
||||
|
||||
it('records streamed reactions when the stream ends', () => {
|
||||
it('records streamed reactions when the stream ends', async () => {
|
||||
const store = useCharacterStore()
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456)
|
||||
|
||||
@@ -105,10 +118,14 @@ describe('store character', () => {
|
||||
expect(store.reactions[0]?.sourceEventId).toBe('spark-1')
|
||||
expect(store.reactions[0]?.createdAt).toBe(123456)
|
||||
|
||||
expect(writeLiteralSpy).toHaveBeenCalledWith('Hello')
|
||||
expect(writeLiteralSpy).toHaveBeenCalledWith(' world')
|
||||
expect(writeFlushSpy).toHaveBeenCalled()
|
||||
expect(endSpy).toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(parserConsumeSpy).toHaveBeenCalled()
|
||||
expect(parserEndSpy).toHaveBeenCalled()
|
||||
expect(writeLiteralSpy).toHaveBeenCalledWith('Hello')
|
||||
expect(writeLiteralSpy).toHaveBeenCalledWith(' world')
|
||||
expect(writeFlushSpy).toHaveBeenCalled()
|
||||
expect(endSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -22,10 +22,16 @@ export interface CharacterSparkNotifyReaction {
|
||||
interface StreamingReactionState {
|
||||
reaction: CharacterSparkNotifyReaction
|
||||
intent: IntentHandle
|
||||
parser: ReturnType<typeof useLlmmarkerParser>
|
||||
parser: ReturnType<ParserFactory>
|
||||
}
|
||||
|
||||
const MAX_REACTIONS = 200
|
||||
type ParserFactory = typeof useLlmmarkerParser
|
||||
let parserFactory: ParserFactory = useLlmmarkerParser
|
||||
|
||||
export function setCharacterLlmMarkerParserFactoryForTest(factory: ParserFactory | null) {
|
||||
parserFactory = factory ?? useLlmmarkerParser
|
||||
}
|
||||
|
||||
export const useCharacterStore = defineStore('character', () => {
|
||||
const { activeCard, systemPrompt } = storeToRefs(useAiriCardStore())
|
||||
@@ -44,7 +50,7 @@ export const useCharacterStore = defineStore('character', () => {
|
||||
behavior: 'queue',
|
||||
})
|
||||
|
||||
const parser = useLlmmarkerParser({
|
||||
const parser = parserFactory({
|
||||
onLiteral: async (literal) => {
|
||||
if (literal)
|
||||
intent.writeLiteral(literal)
|
||||
@@ -79,7 +85,7 @@ export const useCharacterStore = defineStore('character', () => {
|
||||
behavior: 'interrupt',
|
||||
})
|
||||
|
||||
const parser = useLlmmarkerParser({
|
||||
const parser = parserFactory({
|
||||
onLiteral: async (literal) => {
|
||||
if (literal)
|
||||
intent.writeLiteral(literal)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface PluginManifestSummary {
|
||||
name: string
|
||||
entrypoints: Record<string, string | undefined>
|
||||
path: string
|
||||
enabled: boolean
|
||||
loaded: boolean
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
export interface PluginRegistrySnapshot {
|
||||
root: string
|
||||
plugins: PluginManifestSummary[]
|
||||
}
|
||||
|
||||
export interface PluginCapabilityState {
|
||||
key: string
|
||||
state: 'announced' | 'ready'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface PluginHostSessionSummary {
|
||||
id: string
|
||||
manifestName: string
|
||||
phase: string
|
||||
runtime: 'electron' | 'node' | 'web'
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
export interface PluginHostDebugSnapshot {
|
||||
registry: PluginRegistrySnapshot
|
||||
sessions: PluginHostSessionSummary[]
|
||||
capabilities: PluginCapabilityState[]
|
||||
refreshedAt: number
|
||||
}
|
||||
|
||||
interface PluginHostDebugBridge {
|
||||
list: () => Promise<PluginRegistrySnapshot>
|
||||
setEnabled: (payload: { name: string, enabled: boolean, path?: string }) => Promise<PluginRegistrySnapshot>
|
||||
loadEnabled: () => Promise<PluginRegistrySnapshot>
|
||||
load: (payload: { name: string }) => Promise<PluginRegistrySnapshot>
|
||||
unload: (payload: { name: string }) => Promise<PluginRegistrySnapshot>
|
||||
inspect: () => Promise<PluginHostDebugSnapshot>
|
||||
}
|
||||
|
||||
export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-debug', () => {
|
||||
// Runtime bridge injected by the renderer host (Electron).
|
||||
//
|
||||
// Why this exists:
|
||||
// - `stage-pages` is shared by web + desktop.
|
||||
// - Plugin-host IPC only exists in desktop (stage-tamagotchi main process).
|
||||
// - This store keeps UI code shared, and receives runtime-specific operations via `setBridge(...)`.
|
||||
//
|
||||
// In web/non-electron runtimes, bridge stays undefined and debug actions fail with a clear message.
|
||||
const bridge = ref<PluginHostDebugBridge>()
|
||||
const registry = ref<PluginRegistrySnapshot>()
|
||||
const sessions = ref<PluginHostSessionSummary[]>([])
|
||||
const capabilities = ref<PluginCapabilityState[]>([])
|
||||
const refreshedAt = ref<number>()
|
||||
const error = ref<string>()
|
||||
const loading = ref(false)
|
||||
|
||||
const discoveredPlugins = computed(() => registry.value?.plugins ?? [])
|
||||
const enabledPlugins = computed(() => discoveredPlugins.value.filter(plugin => plugin.enabled))
|
||||
const loadedPlugins = computed(() => discoveredPlugins.value.filter(plugin => plugin.loaded))
|
||||
const isAvailable = computed(() => Boolean(bridge.value))
|
||||
|
||||
function setBridge(nextBridge: PluginHostDebugBridge) {
|
||||
// Called by renderer bootstrap once Eventa invoke functions are available.
|
||||
// This turns the shared debug page "online" without coupling it to electron-only imports.
|
||||
bridge.value = nextBridge
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = undefined
|
||||
}
|
||||
|
||||
function assignRegistry(nextRegistry: PluginRegistrySnapshot) {
|
||||
registry.value = nextRegistry
|
||||
}
|
||||
|
||||
function assignInspection(snapshot: PluginHostDebugSnapshot) {
|
||||
assignRegistry(snapshot.registry)
|
||||
sessions.value = snapshot.sessions
|
||||
capabilities.value = snapshot.capabilities
|
||||
refreshedAt.value = snapshot.refreshedAt
|
||||
}
|
||||
|
||||
async function withBridge<T>(run: (activeBridge: PluginHostDebugBridge) => Promise<T>) {
|
||||
// Single guard/flow wrapper for every debug action.
|
||||
//
|
||||
// What it does:
|
||||
// 1) Runtime gate: blocks actions until bridge is registered.
|
||||
// 2) Loading lifecycle: toggles `loading` in a centralized place.
|
||||
// 3) Error normalization: stores user-facing error text for the debug page.
|
||||
//
|
||||
// Why debug store needs this:
|
||||
// - Debug actions are async IPC calls and may fail for runtime/setup reasons.
|
||||
// - A shared wrapper avoids duplicated try/catch/loading logic across each action.
|
||||
// - It gives deterministic UI behavior (same errors/spinner semantics for all commands).
|
||||
if (!bridge.value) {
|
||||
const message = 'Plugin host debug bridge is not available in this runtime.'
|
||||
error.value = message
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
clearError()
|
||||
try {
|
||||
return await run(bridge.value)
|
||||
}
|
||||
catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Plugin host debug request failed.'
|
||||
throw cause
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRegistry() {
|
||||
const nextRegistry = await withBridge(activeBridge => activeBridge.list())
|
||||
assignRegistry(nextRegistry)
|
||||
return nextRegistry
|
||||
}
|
||||
|
||||
async function refreshInspection() {
|
||||
const snapshot = await withBridge(activeBridge => activeBridge.inspect())
|
||||
assignInspection(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
return refreshInspection()
|
||||
}
|
||||
|
||||
async function setEnabled(payload: { name: string, enabled: boolean, path?: string }) {
|
||||
const nextRegistry = await withBridge(activeBridge => activeBridge.setEnabled(payload))
|
||||
assignRegistry(nextRegistry)
|
||||
await refreshInspection()
|
||||
return nextRegistry
|
||||
}
|
||||
|
||||
async function loadEnabled() {
|
||||
const nextRegistry = await withBridge(activeBridge => activeBridge.loadEnabled())
|
||||
assignRegistry(nextRegistry)
|
||||
await refreshInspection()
|
||||
return nextRegistry
|
||||
}
|
||||
|
||||
async function load(payload: { name: string }) {
|
||||
const nextRegistry = await withBridge(activeBridge => activeBridge.load(payload))
|
||||
assignRegistry(nextRegistry)
|
||||
await refreshInspection()
|
||||
return nextRegistry
|
||||
}
|
||||
|
||||
async function unload(payload: { name: string }) {
|
||||
const nextRegistry = await withBridge(activeBridge => activeBridge.unload(payload))
|
||||
assignRegistry(nextRegistry)
|
||||
await refreshInspection()
|
||||
return nextRegistry
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
sessions,
|
||||
capabilities,
|
||||
refreshedAt,
|
||||
loading,
|
||||
error,
|
||||
discoveredPlugins,
|
||||
enabledPlugins,
|
||||
loadedPlugins,
|
||||
isAvailable,
|
||||
|
||||
setBridge,
|
||||
clearError,
|
||||
refreshRegistry,
|
||||
refreshInspection,
|
||||
refreshAll,
|
||||
setEnabled,
|
||||
loadEnabled,
|
||||
load,
|
||||
unload,
|
||||
}
|
||||
})
|
||||
@@ -26,8 +26,8 @@ describe('buildCreateTokenRequest', () => {
|
||||
}
|
||||
|
||||
const expectedCanonicalQuery = 'AccessKeyId=my_access_key_id&Action=CreateToken&Format=JSON&RegionId=cn-shanghai&SignatureMethod=HMAC-SHA1&SignatureNonce=b924c8c3-6d03-4c5d-ad36-d984d3116788&SignatureVersion=1.0&Timestamp=2019-04-18T08%3A32%3A31Z&Version=2019-02-28'
|
||||
const expectedBuiltQueryString = 'GET&%2F&AccessKeyId%3Dmy_access_key_id%26Action%3DCreateToken%26Format%3DJSON%26RegionId%3Dcn-shanghai%26SignatureMethod%3DHMAC-SHA1%26SignatureNonce%3Db924c8c3-6d03-4c5d-ad36-d984d3116788%26SignatureVersion%3D1.0%26Timestamp%3D2019-04-18T08%253A32%253A31Z%26Version%3D2019-02-28'
|
||||
const expectedSignature = 'hHq4yNsPitlfDJ2L0nQPdugdEzM='
|
||||
const expectedBuiltQueryString = 'POST&%2F&AccessKeyId%3Dmy_access_key_id%26Action%3DCreateToken%26Format%3DJSON%26RegionId%3Dcn-shanghai%26SignatureMethod%3DHMAC-SHA1%26SignatureNonce%3Db924c8c3-6d03-4c5d-ad36-d984d3116788%26SignatureVersion%3D1.0%26Timestamp%3D2019-04-18T08%253A32%253A31Z%26Version%3D2019-02-28'
|
||||
const expectedSignature = 'X4/yeE8FUchC5Wv7AZJybEuDWzw='
|
||||
const expectedSignatureEncoded = encodeURIComponent(expectedSignature)
|
||||
const expectedSignedQuery = `Signature=${expectedSignatureEncoded}&${expectedCanonicalQuery}`
|
||||
const expectedUrl = `http://nls-meta.cn-shanghai.aliyuncs.com/?${expectedSignedQuery}`
|
||||
@@ -39,7 +39,7 @@ describe('buildCreateTokenRequest', () => {
|
||||
|
||||
it('creates the expected string to sign', () => {
|
||||
const canonical = canonicalizeQuery(testParameters)
|
||||
const stringToSign = createStringToSign('GET', '/', canonical)
|
||||
const stringToSign = createStringToSign('POST', '/', canonical)
|
||||
expect(stringToSign).toBe(expectedBuiltQueryString)
|
||||
})
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function signStringToBase64(stringToSign: string, accessKeySecret:
|
||||
name: 'HMAC',
|
||||
hash: { name: 'SHA-1' },
|
||||
}
|
||||
|
||||
const cryptoKey = await subtle.importKey(
|
||||
'raw',
|
||||
keyData as Uint8Array<ArrayBuffer>,
|
||||
|
||||
Reference in New Issue
Block a user