feat(plugin-sdk,stage-tamagotchi): demo plugin, and plugin inspector
This commit is contained in:
@@ -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