feat(plugin-sdk,stage-tamagotchi): demo plugin, and plugin inspector

This commit is contained in:
Neko Ayaka
2026-02-16 16:06:28 +08:00
parent cae48f2801
commit e8f6155ec6
16 changed files with 926 additions and 43 deletions
@@ -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.
@@ -0,0 +1,8 @@
{
"apiVersion": "v1",
"kind": "manifest.plugin.airi.moeru.ai",
"name": "devtools-sample-plugin",
"entrypoints": {
"electron": "./devtools-sample-plugin.mjs"
}
}
@@ -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())
+29 -4
View File
@@ -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>
+29 -12
View File
@@ -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>,