refactor(stage-pages): split context flow into small components

This commit is contained in:
Neko Ayaka
2026-01-09 21:37:50 +08:00
parent 8736db16b3
commit 20c167d790
11 changed files with 1337 additions and 1125 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { Section } from '@proj-airi/stage-ui/components'
import { Button, FieldTextArea, SelectTab } from '@proj-airi/ui'
const emit = defineEmits<{
(event: 'sendContextUpdate'): void
(event: 'sendSparkNotify'): void
}>()
const testStrategy = defineModel<ContextUpdateStrategy>('testStrategy', { required: true })
const testPayload = defineModel<string>('testPayload', { required: true })
const testSparkNotifyPayload = defineModel<string>('testSparkNotifyPayload', { required: true })
const strategyOptions = [
{ label: 'Replace', value: ContextUpdateStrategy.ReplaceSelf },
{ label: 'Append', value: ContextUpdateStrategy.AppendSelf },
]
</script>
<template>
<div :class="['flex', 'flex-col', 'gap-2']">
<Section title="Send" icon="i-solar:plain-2-bold-duotone" inner-class="gap-3" :expand="false">
<div :class="['flex', 'flex-col', 'gap-2']">
<div :class="['text-xs', 'font-medium', 'text-neutral-500', 'dark:text-neutral-400']">
Strategy
</div>
<SelectTab
v-model="testStrategy"
size="sm"
:options="strategyOptions"
/>
<FieldTextArea
v-model="testPayload"
label="Payload"
description="Raw text payload sent as ContextUpdate.text. JSON is allowed."
:input-class="['font-mono', 'min-h-32']"
/>
<div :class="['flex', 'justify-end']">
<Button label="Send context update" icon="i-solar:plain-2-bold-duotone" size="sm" @click="emit('sendContextUpdate')" />
</div>
</div>
</Section>
<Section title="Simulate incoming" icon="i-solar:plain-2-bold-duotone" inner-class="gap-3" :expand="false">
<FieldTextArea
v-model="testSparkNotifyPayload"
label="spark:notify"
description="Raw JSON payload for spark:notify. Required: headline, destinations[]. id/eventId will be auto-filled if missing."
:input-class="['font-mono', 'min-h-44', 'overflow-hidden']"
/>
<div :class="['flex', 'justify-end']">
<Button
label="Send spark:notify"
icon="i-solar:bell-bing-bold-duotone"
size="sm"
@click="emit('sendSparkNotify')"
/>
</div>
</Section>
</div>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { FlowEntry, SparkNotifyEntryState } from '../context-flow-types'
import ContextFlowPreview from './context-flow-preview.vue'
import ContextFlowSparkNotify from './context-flow-spark-notify.vue'
import { useContextFlowFormatters } from '../composables/use-context-flow-formatters'
defineProps<{ entry: FlowEntry, sparkNotifyState?: SparkNotifyEntryState }>()
const {
channelBadgeClasses,
directionBadgeClasses,
directionIconClass,
formatPayload,
formatTimestamp,
getEventSource,
sourceBadgeClasses,
} = useContextFlowFormatters()
</script>
<template>
<div
:class="[
'rounded-xl',
'border',
'border-neutral-200/70',
'bg-neutral-50/80',
'p-4',
'shadow-sm',
'dark:border-neutral-800/80',
'dark:bg-neutral-950/60',
]"
>
<div :class="['flex', 'items-start', 'justify-between', 'gap-3']">
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2', 'text-xs']">
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'flex', 'items-center', 'justify-center', ...directionBadgeClasses(entry.direction)]">
<span :class="['size-3.5', directionIconClass(entry.direction)]" :aria-label="entry.direction" />
</span>
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', ...channelBadgeClasses(entry.channel)]">
{{ entry.channel }}
</span>
<span
v-if="getEventSource(entry)"
:class="['rounded-full', 'border', 'px-2', 'py-0.5', ...sourceBadgeClasses()]"
>
{{ getEventSource(entry) }}
</span>
<span :class="['font-semibold', 'text-neutral-800', 'dark:text-neutral-100']">
{{ entry.type }}
</span>
</div>
<span :class="['font-mono', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
{{ formatTimestamp(entry.timestamp) }}
</span>
</div>
<div v-if="entry.summary" :class="['mt-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
{{ entry.summary }}
</div>
<ContextFlowSparkNotify
v-if="entry.type === 'spark:notify'"
:entry-id="entry.id"
:state="sparkNotifyState"
/>
<ContextFlowPreview :entry="entry" />
<details :class="['mt-3']">
<summary :class="['cursor-pointer', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
Details
</summary>
<pre :class="['mt-2', 'max-h-64', 'overflow-auto', 'rounded-lg', 'bg-neutral-900/90', 'p-3', 'text-xs', 'text-neutral-100']">
{{ formatPayload(entry.payload) }}
</pre>
</details>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { FlowDirection } from '../context-flow-types'
import { Button, FieldCheckbox, FieldInput, SelectTab } from '@proj-airi/ui'
const emit = defineEmits<{ (event: 'clear'): void }>()
const directionFilter = defineModel<'all' | FlowDirection>('directionFilter', { required: true })
const showIncoming = defineModel<boolean>('showIncoming', { required: true })
const showOutgoing = defineModel<boolean>('showOutgoing', { required: true })
const showServer = defineModel<boolean>('showServer', { required: true })
const showBroadcast = defineModel<boolean>('showBroadcast', { required: true })
const showChat = defineModel<boolean>('showChat', { required: true })
const showDevtools = defineModel<boolean>('showDevtools', { required: true })
const maxEntries = defineModel<string>('maxEntries', { required: true })
const directionOptions = [
{ label: 'All', value: 'all' },
{ label: 'Incoming', value: 'incoming' },
{ label: 'Outgoing', value: 'outgoing' },
]
</script>
<template>
<div :class="['flex', 'flex-col', 'gap-6', 'rounded-xl', 'bg-neutral-50', 'p-4', 'dark:bg-[rgba(0,0,0,0.3)]', 'h-fit']">
<div :class="['flex', 'items-center', 'gap-2', 'text-sm', 'font-semibold', 'text-neutral-600', 'dark:text-neutral-300']">
<div :class="['size-5', 'i-solar:filter-bold-duotone']" />
Filters
</div>
<div :class="['flex', 'flex-col', 'gap-3']">
<div :class="['flex', 'flex-col', 'gap-2', 'w-full']">
<div :class="['text-xs', 'font-medium', 'text-neutral-500', 'dark:text-neutral-400']">
Direction
</div>
<SelectTab
v-model="directionFilter"
size="sm"
:options="directionOptions"
/>
</div>
<div :class="['flex', 'flex-col', 'gap-2', 'w-full']">
<div :class="['text-xs', 'font-medium', 'text-neutral-500', 'dark:text-neutral-400']">
Visibility
</div>
<div :class="['flex', 'flex-wrap', 'gap-2']">
<FieldCheckbox v-model="showIncoming" label="Show incoming" />
<FieldCheckbox v-model="showOutgoing" label="Show outgoing" />
</div>
</div>
<div :class="['flex', 'flex-col', 'gap-2', 'w-full']">
<div :class="['text-xs', 'font-medium', 'text-neutral-500', 'dark:text-neutral-400']">
Channels
</div>
<div :class="['flex', 'flex-wrap', 'gap-2']">
<FieldCheckbox v-model="showServer" label="Server" />
<FieldCheckbox v-model="showBroadcast" label="Broadcast" />
<FieldCheckbox v-model="showChat" label="Chat" />
<FieldCheckbox v-model="showDevtools" label="Devtools" />
</div>
</div>
<div :class="['flex', 'flex-col', 'gap-2', 'w-full']">
<FieldInput
v-model="maxEntries"
label="Max entries"
description="50-1000 (default 200)"
type="number"
/>
</div>
<div :class="['flex', 'items-end', 'justify-end', 'w-full']">
<Button label="Clear" icon="i-solar:trash-bin-trash-bold-duotone" size="sm" @click="emit('clear')" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { FlowEntry } from '../context-flow-types'
import { useContextFlowFormatters } from '../composables/use-context-flow-formatters'
defineProps<{ entry: FlowEntry }>()
const { buildPreviewItems } = useContextFlowFormatters()
</script>
<template>
<div :class="['mt-3', 'grid', 'gap-2']">
<div :class="['text-[11px]', 'uppercase', 'tracking-[0.08em]', 'text-neutral-400', 'dark:text-neutral-500']">
Preview
</div>
<div v-if="buildPreviewItems(entry).length" :class="['grid', 'gap-2', 'sm:grid-cols-2']">
<div
v-for="item in buildPreviewItems(entry)"
:key="`${entry.id}-${item.label}`"
:class="[
'rounded-lg',
'border',
'border-neutral-200/70',
'bg-white/80',
'p-3',
'dark:border-neutral-800/80',
'dark:bg-neutral-900/70',
]"
>
<div :class="['text-[11px]', 'uppercase', 'tracking-[0.06em]', 'text-neutral-400', 'dark:text-neutral-500']">
{{ item.label }}
</div>
<pre :class="['mt-2', 'max-h-40', 'overflow-auto', 'whitespace-pre-wrap', 'break-words', 'text-xs', 'font-mono', 'text-neutral-800', 'dark:text-neutral-100']">
{{ item.value || '-' }}
</pre>
</div>
</div>
<div v-else :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
No preview available.
</div>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { SparkNotifyEntryState } from '../context-flow-types'
import { useContextFlowFormatters } from '../composables/use-context-flow-formatters'
defineProps<{ entryId: number, state?: SparkNotifyEntryState }>()
const { buildSparkCommandPreview } = useContextFlowFormatters()
</script>
<template>
<div
:class="[
'mt-3',
'rounded-lg',
'border',
'border-neutral-200/70',
'bg-white/80',
'p-3',
'text-xs',
'dark:border-neutral-800/80',
'dark:bg-neutral-900/70',
'grid',
'gap-3',
]"
>
<div v-if="state" :class="['grid', 'gap-3']">
<div :class="['flex', 'items-center', 'justify-between', 'gap-2']">
<div :class="['flex', 'items-center', 'gap-2', 'text-neutral-700', 'dark:text-neutral-200']">
<span
v-if="state.handling"
:class="['size-3.5', 'i-solar:spinner-line-duotone', 'animate-spin']"
/>
<span>
{{ state.handling ? 'Handling spark:notify...' : 'spark:notify handled' }}
</span>
</div>
<span
v-if="state.endedAt"
:class="['text-[11px]', 'text-neutral-400', 'dark:text-neutral-500']"
>
{{ Math.max(0, (state.endedAt ?? 0) - (state.startedAt ?? 0)) }}ms
</span>
</div>
<div :class="['text-[11px]', 'text-neutral-500', 'dark:text-neutral-400']">
eventId: {{ state.eventId }} · sparkId: {{ state.sparkId ?? '-' }}
</div>
<div
v-if="state.error"
:class="['rounded-md', 'border', 'border-rose-500/40', 'bg-rose-500/10', 'px-2', 'py-1.5', 'text-rose-600', 'dark:text-rose-300']"
>
{{ state.error }}
</div>
<div :class="['grid', 'gap-2']">
<div :class="['text-[11px]', 'uppercase', 'tracking-[0.06em]', 'text-neutral-400', 'dark:text-neutral-500']">
Output text
</div>
<pre :class="['max-h-40', 'overflow-auto', 'whitespace-pre-wrap', 'break-words', 'text-xs', 'font-mono', 'text-neutral-800', 'dark:text-neutral-100']">
{{ state.reaction || '-' }}
</pre>
</div>
<div :class="['grid', 'gap-2']">
<div :class="['text-[11px]', 'uppercase', 'tracking-[0.06em]', 'text-neutral-400', 'dark:text-neutral-500']">
Commands
</div>
<div v-if="state.commands.length" :class="['grid', 'gap-2']">
<div
v-for="(command, index) in state.commands"
:key="command.commandId ?? `${entryId}-${index}`"
:class="[
'rounded-lg',
'border',
'border-neutral-200/70',
'bg-white/80',
'p-3',
'dark:border-neutral-800/80',
'dark:bg-neutral-950/60',
'grid',
'gap-2',
]"
>
<div :class="['text-xs', 'font-semibold', 'text-neutral-700', 'dark:text-neutral-200']">
Command {{ index + 1 }}
</div>
<div v-if="buildSparkCommandPreview(command).length" :class="['grid', 'gap-2', 'sm:grid-cols-2']">
<div
v-for="item in buildSparkCommandPreview(command)"
:key="`${entryId}-${index}-${item.label}`"
:class="[
'rounded-lg',
'border',
'border-neutral-200/70',
'bg-white/80',
'p-2.5',
'dark:border-neutral-800/80',
'dark:bg-neutral-900/70',
]"
>
<div :class="['text-[11px]', 'uppercase', 'tracking-[0.06em]', 'text-neutral-400', 'dark:text-neutral-500']">
{{ item.label }}
</div>
<pre :class="['mt-1.5', 'max-h-32', 'overflow-auto', 'whitespace-pre-wrap', 'break-words', 'text-xs', 'font-mono', 'text-neutral-800', 'dark:text-neutral-100']">
{{ item.value || '-' }}
</pre>
</div>
</div>
<div v-else :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
No command details available.
</div>
</div>
</div>
<div v-else :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
No commands returned.
</div>
</div>
</div>
<div v-else :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
No spark:notify handling data yet.
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { FlowEntry, SparkNotifyEntryState } from '../context-flow-types'
import { Input } from '@proj-airi/ui'
import { nextTick, ref, watch } from 'vue'
import ContextFlowEntryCard from './context-flow-entry-card.vue'
const props = defineProps<{
entries: FlowEntry[]
getSparkNotifyState: (entry: FlowEntry) => SparkNotifyEntryState | undefined
}>()
const filterText = defineModel<string>('filterText', { required: true })
const streamContainer = ref<HTMLDivElement>()
async function scrollToTop() {
await nextTick()
if (streamContainer.value)
streamContainer.value.scrollTop = 0
}
watch(() => props.entries.length, scrollToTop)
watch(() => filterText.value, scrollToTop)
</script>
<template>
<div :class="['flex', 'flex-col', 'gap-2']">
<div :class="['flex']">
<Input
v-model="filterText"
placeholder="Search type, source, text..."
/>
</div>
<div
ref="streamContainer"
:class="[
'max-h-[60vh]',
'min-h-[360px]',
'overflow-y-auto',
'rounded-lg',
'bg-white/70',
'p-3',
'dark:bg-neutral-950/50',
]"
>
<div
v-if="!entries.length"
:class="['h-full', 'w-full', 'flex', 'items-center', 'justify-center', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']"
>
No data yet. Trigger a chat, send a context update, or enable broadcast capture.
</div>
<div v-else v-auto-animate :class="['grid', 'gap-3']">
<ContextFlowEntryCard
v-for="entry in entries"
:key="entry.id"
:entry="entry"
:spark-notify-state="getSparkNotifyState(entry)"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
import type { FlowEntry } from '../context-flow-types'
import { describe, expect, it } from 'vitest'
import { useContextFlowFormatters } from './use-context-flow-formatters'
describe('useContextFlowFormatters', () => {
const { buildPreviewItems, formatDestinations } = useContextFlowFormatters()
it('formats destinations consistently', () => {
expect(formatDestinations(['alpha', 'beta'])).toBe('alpha, beta')
expect(formatDestinations('single')).toBe('single')
})
it('builds preview items for context updates', () => {
const entry: FlowEntry = {
id: 1,
timestamp: Date.now(),
direction: 'incoming',
channel: 'server',
type: 'context:update',
summary: 'test',
payload: {
data: {
text: 'Hello world',
destinations: ['character'],
},
},
searchText: '',
}
const items = buildPreviewItems(entry)
expect(items.map(item => item.label)).toEqual(['Text', 'Destinations'])
expect(items[0]?.value).toContain('Hello world')
expect(items[1]?.value).toContain('character')
})
})
@@ -0,0 +1,244 @@
import type { WebSocketEvents } from '@proj-airi/server-sdk'
import type { FlowChannel, FlowDirection, FlowEntry, PreviewItem } from '../context-flow-types'
const previewMaxLength = 420
function truncateText(value: string, limit = 160) {
if (value.length <= limit)
return value
return `${value.slice(0, limit)}...`
}
function formatDestinations(destinations: unknown) {
if (!destinations)
return ''
if (Array.isArray(destinations))
return destinations.join(', ')
if (typeof destinations === 'string')
return destinations
try {
return JSON.stringify(destinations)
}
catch {
return String(destinations)
}
}
function getPayloadData(entry: FlowEntry) {
const payload = entry.payload as Record<string, any> | undefined
if (!payload)
return undefined
return payload.data ?? payload
}
function getEventSource(entry: FlowEntry) {
const payload = entry.payload as Record<string, any> | undefined
if (!payload)
return undefined
return payload.source as string | undefined
}
function summarizeContextUpdate(update: { text?: string, content?: unknown, destinations?: unknown }) {
const summaryParts: string[] = []
if (update.text) {
summaryParts.push(`text="${truncateText(update.text, 120)}"`)
}
if (update.content !== undefined) {
const contentText = typeof update.content === 'string'
? update.content
: (() => {
try {
return JSON.stringify(update.content)
}
catch {
return '[unserializable]'
}
})()
summaryParts.push(`content="${truncateText(contentText, 120)}"`)
}
if (update.destinations !== undefined) {
summaryParts.push(`destinations="${truncateText(formatDestinations(update.destinations), 120)}"`)
}
return summaryParts.join(' ')
}
function toPreviewValue(value: unknown) {
if (value === undefined || value === null)
return ''
if (typeof value === 'string')
return value
try {
return JSON.stringify(value, null, 2)
}
catch {
return String(value)
}
}
function formatPreviewValue(value: unknown) {
const text = toPreviewValue(value)
if (!text)
return ''
return truncateText(text, previewMaxLength)
}
function getContextUpdatePreview(entry: FlowEntry) {
const candidate = getPayloadData(entry) as Record<string, any> | undefined
if (!candidate || (candidate.text === undefined && candidate.content === undefined && candidate.destinations === undefined))
return null
return {
text: candidate.text as string | undefined,
content: candidate.content as unknown,
destinations: candidate.destinations as unknown,
}
}
function buildPreviewItems(entry: FlowEntry): PreviewItem[] {
const items: PreviewItem[] = []
const contextPreview = getContextUpdatePreview(entry)
if (contextPreview) {
if (contextPreview.text) {
items.push({ label: 'Text', value: formatPreviewValue(contextPreview.text) })
}
if (contextPreview.content !== undefined) {
items.push({ label: 'Content', value: formatPreviewValue(contextPreview.content) })
}
if (contextPreview.destinations !== undefined) {
items.push({ label: 'Destinations', value: formatPreviewValue(formatDestinations(contextPreview.destinations)) })
}
return items
}
const payload = getPayloadData(entry) as Record<string, any> | undefined
if (payload?.destinations !== undefined) {
items.push({ label: 'Destinations', value: formatPreviewValue(formatDestinations(payload.destinations)) })
}
if (entry.type.startsWith('spark:')) {
if (payload?.headline)
items.push({ label: 'Headline', value: formatPreviewValue(payload.headline) })
if (payload?.state)
items.push({ label: 'State', value: formatPreviewValue(payload.state) })
if (payload?.intent)
items.push({ label: 'Intent', value: formatPreviewValue(payload.intent) })
}
if (payload?.messageText) {
items.push({ label: 'Message', value: formatPreviewValue(payload.messageText) })
}
else if (payload?.literal) {
items.push({ label: 'Token', value: formatPreviewValue(payload.literal) })
}
else if (payload?.special) {
items.push({ label: 'Token', value: formatPreviewValue(payload.special) })
}
else if (payload?.message) {
items.push({ label: 'Message', value: formatPreviewValue(payload.message) })
}
else if (payload?.name && entry.type === 'module:announce') {
items.push({ label: 'Module', value: formatPreviewValue(payload.name) })
}
else if (payload?.text) {
items.push({ label: 'Text', value: formatPreviewValue(payload.text) })
}
else if (payload?.transcription) {
items.push({ label: 'Transcription', value: formatPreviewValue(payload.transcription) })
}
else if (entry.summary) {
items.push({ label: 'Summary', value: formatPreviewValue(entry.summary) })
}
return items
}
function buildSparkCommandPreview(command: WebSocketEvents['spark:command']): PreviewItem[] {
const items: PreviewItem[] = []
if (command.intent)
items.push({ label: 'Intent', value: formatPreviewValue(command.intent) })
if (command.priority)
items.push({ label: 'Priority', value: formatPreviewValue(command.priority) })
if (command.interrupt !== undefined)
items.push({ label: 'Interrupt', value: formatPreviewValue(command.interrupt) })
if (command.destinations?.length)
items.push({ label: 'Destinations', value: formatPreviewValue(formatDestinations(command.destinations)) })
if (command.ack)
items.push({ label: 'Ack', value: formatPreviewValue(command.ack) })
if (command.guidance !== undefined)
items.push({ label: 'Guidance', value: formatPreviewValue(command.guidance) })
if (command.contexts !== undefined)
items.push({ label: 'Contexts', value: formatPreviewValue(command.contexts) })
return items
}
function formatTimestamp(value: number) {
const date = new Date(value)
return date.toLocaleTimeString('en-US', { hour12: false })
}
function formatPayload(payload: unknown) {
if (payload === undefined)
return '-'
if (typeof payload === 'string')
return payload
try {
return JSON.stringify(payload, null, 2)
}
catch {
return String(payload)
}
}
function directionBadgeClasses(direction: FlowDirection) {
if (direction === 'incoming') {
return [
'bg-complementary-500/15',
'text-complementary-600',
'dark:text-complementary-300',
'border-complementary-500/30',
]
}
return [
'bg-primary-500/15',
'text-primary-600',
'dark:text-primary-300',
'border-primary-500/30',
]
}
function directionIconClass(direction: FlowDirection) {
return direction === 'incoming' ? 'i-solar:arrow-down-linear' : 'i-solar:arrow-up-linear'
}
function channelBadgeClasses(channel: FlowChannel) {
switch (channel) {
case 'server':
return ['bg-orange-500/15', 'text-orange-600', 'dark:text-orange-300', 'border-orange-500/30']
case 'broadcast':
return ['bg-violet-500/15', 'text-violet-600', 'dark:text-violet-300', 'border-violet-500/30']
case 'chat':
return ['bg-lime-500/15', 'text-lime-600', 'dark:text-lime-300', 'border-lime-500/30']
default:
return ['bg-neutral-400/15', 'text-neutral-600', 'dark:text-neutral-300', 'border-neutral-500/30']
}
}
function sourceBadgeClasses() {
return ['bg-neutral-400/15', 'text-neutral-600', 'dark:text-neutral-300', 'border-neutral-500/30']
}
export function useContextFlowFormatters() {
return {
buildPreviewItems,
buildSparkCommandPreview,
channelBadgeClasses,
directionBadgeClasses,
directionIconClass,
formatDestinations,
formatPayload,
formatTimestamp,
getEventSource,
getPayloadData,
sourceBadgeClasses,
summarizeContextUpdate,
truncateText,
}
}
@@ -0,0 +1,33 @@
import type { WebSocketEvents } from '@proj-airi/server-sdk'
type Optional<T> = T | undefined
export type FlowDirection = 'incoming' | 'outgoing'
export type FlowChannel = 'server' | 'broadcast' | 'chat' | 'devtools'
export interface FlowEntry {
id: number
timestamp: number
direction: FlowDirection
channel: FlowChannel
type: string
summary?: string
payload?: unknown
searchText: string
}
export interface PreviewItem {
label: string
value: string
}
export interface SparkNotifyEntryState {
eventId: string
sparkId?: string
handling: boolean
commands: WebSocketEvents['spark:command'][]
reaction: string
startedAt: number
endedAt?: number
error?: Optional<string>
}
@@ -0,0 +1,584 @@
<script setup lang="ts">
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
import type { ChatStreamEvent, ContextMessage } from '@proj-airi/stage-ui/types/chat'
import type { FlowDirection, FlowEntry, SparkNotifyEntryState } from './context-flow-types'
import { errorMessageFrom } from '@moeru/std'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { useCharacterStore } from '@proj-airi/stage-ui/stores/character'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character-orchestrator'
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME, useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { Callout } from '@proj-airi/ui'
import { useBroadcastChannel } from '@vueuse/core'
import { nanoid } from 'nanoid'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import ContextFlowActions from './components/context-flow-actions.vue'
import ContextFlowFilters from './components/context-flow-filters.vue'
import ContextFlowStream from './components/context-flow-stream.vue'
import { useContextFlowFormatters } from './composables/use-context-flow-formatters'
type DirectionFilter = 'all' | FlowDirection
const {
formatDestinations,
getPayloadData,
summarizeContextUpdate,
truncateText,
} = useContextFlowFormatters()
const chatStore = useChatStore()
const characterStore = useCharacterStore()
const characterOrchestratorStore = useCharacterOrchestratorStore()
const serverChannelStore = useModsServerChannelStore()
const entries = ref<FlowEntry[]>([])
const showIncoming = ref(true)
const showOutgoing = ref(true)
const showServer = ref(true)
const showBroadcast = ref(false)
const showChat = ref(false)
const showDevtools = ref(false)
const filterText = ref('')
const maxEntries = ref('200')
const testPayload = ref('{"type":"coding:context","data":{"file":{"path":"README.md"}}}')
const testStrategy = ref<ContextUpdateStrategy>(ContextUpdateStrategy.ReplaceSelf)
const testSparkNotifyPayload = ref(JSON.stringify({
kind: 'ping',
urgency: 'immediate',
headline: 'Minecraft entity `zombie` attacked you, health dropped 2 points.',
note: 'Triggered from minecraft',
destinations: ['character'],
payload: {
message: 'Hello from Context Flow devtools',
},
}, null, 2))
const directionFilter = ref<DirectionFilter>('all')
const sparkNotifyStates = ref<Map<string, SparkNotifyEntryState>>(new Map())
const maxEntriesValue = computed(() => {
const parsed = Number.parseInt(maxEntries.value, 10)
if (!Number.isFinite(parsed))
return 200
return Math.min(Math.max(parsed, 50), 1000)
})
const filteredEntries = computed(() => {
const query = filterText.value.trim().toLowerCase()
const filtered = entries.value.filter((entry) => {
if (directionFilter.value !== 'all' && entry.direction !== directionFilter.value)
return false
if (!showIncoming.value && entry.direction === 'incoming')
return false
if (!showOutgoing.value && entry.direction === 'outgoing')
return false
if (!showServer.value && entry.channel === 'server')
return false
if (!showBroadcast.value && entry.channel === 'broadcast')
return false
if (!showChat.value && entry.channel === 'chat')
return false
if (!showDevtools.value && entry.channel === 'devtools')
return false
if (!query)
return true
return entry.searchText.includes(query)
})
return filtered.slice().reverse()
})
function normalizePayload(payload: unknown) {
try {
return JSON.parse(JSON.stringify(payload)) as unknown
}
catch {
return payload
}
}
function getSparkNotifyReaction(eventId: string) {
const reactions = characterStore.reactions
for (let index = reactions.length - 1; index >= 0; index -= 1) {
const reaction = reactions[index]
if (reaction.sourceEventId === eventId)
return reaction
}
return undefined
}
function getSparkNotifyEntryState(entry: FlowEntry) {
if (entry.type !== 'spark:notify')
return undefined
const payload = getPayloadData(entry) as { id?: string } | undefined
if (!payload?.id)
return undefined
return sparkNotifyStates.value.get(payload.id)
}
function setSparkNotifyState(nextState: SparkNotifyEntryState) {
const nextMap = new Map(sparkNotifyStates.value)
nextMap.set(nextState.eventId, nextState)
sparkNotifyStates.value = nextMap
}
function updateSparkNotifyState(eventId: string, updater: (state: SparkNotifyEntryState) => SparkNotifyEntryState) {
const current = sparkNotifyStates.value.get(eventId)
if (!current)
return
setSparkNotifyState(updater(current))
}
function summarizeServerEvent(event: { type: string, data: Record<string, any> }) {
switch (event.type) {
case 'module:announce':
return `name=${event.data.name} events=${event.data.possibleEvents?.length ?? 0}`
case 'spark:notify':
return [
event.data.headline ? `headline="${truncateText(String(event.data.headline), 120)}"` : '',
event.data.destinations ? `destinations="${truncateText(formatDestinations(event.data.destinations), 120)}"` : '',
].filter(Boolean).join(' ')
case 'spark:emit':
return [
event.data.state ? `state=${event.data.state}` : '',
event.data.destinations ? `destinations="${truncateText(formatDestinations(event.data.destinations), 120)}"` : '',
].filter(Boolean).join(' ')
case 'spark:command':
return [
event.data.intent ? `intent=${event.data.intent}` : '',
event.data.priority ? `priority=${event.data.priority}` : '',
event.data.destinations ? `destinations="${truncateText(formatDestinations(event.data.destinations), 120)}"` : '',
].filter(Boolean).join(' ')
default:
if (event.data.text)
return `text="${truncateText(String(event.data.text), 120)}"`
if (event.data.transcription)
return `transcription="${truncateText(String(event.data.transcription), 120)}"`
return ''
}
}
function buildSearchText(entry: Omit<FlowEntry, 'searchText'>) {
const payloadText = typeof entry.payload === 'string'
? entry.payload
: (() => {
try {
return JSON.stringify(entry.payload)
}
catch {
return ''
}
})()
return [
entry.direction,
entry.channel,
entry.type,
entry.summary ?? '',
payloadText,
].join(' ').toLowerCase()
}
let entryId = 0
function pushEntry(entry: Omit<FlowEntry, 'id' | 'timestamp' | 'searchText'>) {
const normalizedPayload = normalizePayload(entry.payload)
const nextEntry: FlowEntry = {
...entry,
id: entryId++,
timestamp: Date.now(),
payload: normalizedPayload,
searchText: '',
}
nextEntry.searchText = buildSearchText(nextEntry)
entries.value.push(nextEntry)
if (entries.value.length > maxEntriesValue.value)
entries.value.splice(0, entries.value.length - maxEntriesValue.value)
}
function clearEntries() {
entries.value = []
}
function sendTestContextUpdate() {
const text = testPayload.value.trim()
if (!text)
return
serverChannelStore.sendContextUpdate({
strategy: testStrategy.value,
text,
})
pushEntry({
direction: 'outgoing',
channel: 'devtools',
type: 'context:update',
summary: `strategy=${testStrategy.value} length=${text.length}`,
payload: { strategy: testStrategy.value, text },
})
}
async function sendTestSparkNotify() {
const raw = testSparkNotifyPayload.value.trim()
if (!raw)
return
let parsed: any
try {
parsed = JSON.parse(raw)
}
catch (err) {
toast(`Invalid spark:notify: ${errorMessageFrom(err)}`)
return
}
const destinations = Array.isArray(parsed?.destinations) ? parsed.destinations.filter((d: unknown) => typeof d === 'string') : []
if (!parsed?.headline || !destinations.length) {
toast('Missing required fields (headline, destinations[]) for spark:notify')
return
}
// TODO(@nekomeowww): improve server event, support to have zod or valibot schema validation for better cross runtime handling
const notify = {
id: typeof parsed.id === 'string' && parsed.id ? parsed.id : nanoid(),
eventId: typeof parsed.eventId === 'string' && parsed.eventId ? parsed.eventId : nanoid(),
lane: typeof parsed.lane === 'string' ? parsed.lane : undefined,
kind: parsed.kind === 'alarm' || parsed.kind === 'ping' || parsed.kind === 'reminder' ? parsed.kind : 'ping',
urgency: parsed.urgency === 'immediate' || parsed.urgency === 'soon' || parsed.urgency === 'later' ? parsed.urgency : 'immediate',
headline: String(parsed.headline),
note: typeof parsed.note === 'string' ? parsed.note : undefined,
payload: parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : undefined,
ttlMs: typeof parsed.ttlMs === 'number' ? parsed.ttlMs : undefined,
requiresAck: typeof parsed.requiresAck === 'boolean' ? parsed.requiresAck : undefined,
destinations,
metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
}
const simulatedEvent: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']> = {
type: 'spark:notify',
source: 'devtools',
data: notify,
}
pushEntry({
direction: 'incoming',
channel: 'server',
type: 'spark:notify',
summary: summarizeServerEvent(simulatedEvent as any),
payload: simulatedEvent,
})
try {
setSparkNotifyState({
eventId: notify.id,
sparkId: notify.eventId,
handling: true,
commands: [],
reaction: '',
startedAt: Date.now(),
})
const result = await characterOrchestratorStore.handleSparkNotify(simulatedEvent)
const reaction = getSparkNotifyReaction(notify.id)
updateSparkNotifyState(notify.id, current => ({
...current,
sparkId: notify.eventId,
handling: false,
commands: result?.commands ?? [],
reaction: reaction?.message ?? '',
endedAt: Date.now(),
}))
if (result?.commands?.length) {
for (const command of result.commands) {
serverChannelStore.send({
type: 'spark:command',
data: command,
})
}
}
}
catch (error) {
toast(`Error handling spark:notify: ${errorMessageFrom(error)}`)
updateSparkNotifyState(notify.id, current => ({
...current,
handling: false,
endedAt: Date.now(),
error: errorMessageFrom(error),
}))
}
}
const { data: incomingContext } = useBroadcastChannel<ContextMessage, ContextMessage>({
name: CONTEXT_CHANNEL_NAME,
})
const { data: incomingStreamEvent } = useBroadcastChannel<ChatStreamEvent, ChatStreamEvent>({
name: CHAT_STREAM_CHANNEL_NAME,
})
const cleanupFns: Array<() => void> = []
onMounted(() => {
cleanupFns.push(serverChannelStore.onContextUpdate((event) => {
pushEntry({
direction: 'incoming',
channel: 'server',
type: event.type,
summary: [
`source=${event.source}`,
`strategy=${event.data.strategy}`,
summarizeContextUpdate(event.data),
].filter(Boolean).join(' '),
payload: event,
})
}))
const serverEventTypes = [
'module:announce',
'module:configure',
'module:authenticated',
'error',
'spark:notify',
'spark:emit',
'spark:command',
'input:text',
'input:text:voice',
'output:gen-ai:chat:message',
'output:gen-ai:chat:complete',
'output:gen-ai:chat:tool-call',
] as const
for (const type of serverEventTypes) {
cleanupFns.push(serverChannelStore.onEvent(type, (event) => {
if (event.type === 'spark:notify') {
const eventId = (event as WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>).data?.id
if (eventId && !sparkNotifyStates.value.has(eventId)) {
const sparkId = (event as WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>).data?.eventId
setSparkNotifyState({
eventId,
sparkId,
handling: true,
commands: [],
reaction: '',
startedAt: Date.now(),
})
}
}
pushEntry({
direction: 'incoming',
channel: 'server',
type: event.type,
summary: summarizeServerEvent(event as any),
payload: event,
})
}))
}
cleanupFns.push(
chatStore.onBeforeMessageComposed(async (message, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'before-compose',
summary: truncateText(message),
payload: { message, context },
})
}),
chatStore.onAfterMessageComposed(async (message, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'after-compose',
summary: truncateText(message),
payload: { message, context },
})
}),
chatStore.onBeforeSend(async (message, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'before-send',
summary: truncateText(message),
payload: { message, context },
})
}),
chatStore.onAfterSend(async (message, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'after-send',
summary: truncateText(message),
payload: { message, context },
})
}),
chatStore.onTokenLiteral(async (literal, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'token-literal',
summary: truncateText(literal, 80),
payload: { literal, context },
})
}),
chatStore.onTokenSpecial(async (special, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'token-special',
summary: truncateText(special, 80),
payload: { special, context },
})
}),
chatStore.onStreamEnd(async (context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'stream-end',
summary: 'stream completed',
payload: { context },
})
}),
chatStore.onAssistantResponseEnd(async (message, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'assistant-end',
summary: truncateText(message),
payload: { message, context },
})
}),
chatStore.onAssistantMessage(async (message, messageText, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'assistant-message',
summary: truncateText(messageText),
payload: { message, messageText, context },
})
}),
chatStore.onChatTurnComplete(async (chat, context) => {
pushEntry({
direction: 'outgoing',
channel: 'chat',
type: 'chat-turn-complete',
summary: truncateText(chat.outputText),
payload: { chat, context },
})
}),
)
})
watch(incomingContext, (event) => {
if (!event)
return
pushEntry({
direction: 'incoming',
channel: 'broadcast',
type: 'context:broadcast',
summary: [
`source=${event.source}`,
`strategy=${event.strategy}`,
summarizeContextUpdate(event),
].filter(Boolean).join(' '),
payload: event,
})
})
watch(incomingStreamEvent, (event) => {
if (!event)
return
pushEntry({
direction: 'incoming',
channel: 'broadcast',
type: `stream:${event.type}`,
summary: event.type === 'token-literal'
? truncateText(event.literal, 80)
: event.type === 'token-special'
? truncateText(event.special, 80)
: event.type === 'assistant-message'
? truncateText(event.messageText ?? '', 120)
: `session=${event.sessionId}`,
payload: event,
})
})
watch(() => characterStore.reactions.length, () => {
for (const state of sparkNotifyStates.value.values()) {
if (state.reaction)
continue
const reaction = getSparkNotifyReaction(state.eventId)
if (!reaction)
continue
updateSparkNotifyState(state.eventId, current => ({
...current,
reaction: reaction.message,
handling: false,
endedAt: current.endedAt ?? Date.now(),
}))
}
})
watch(maxEntriesValue, () => {
if (entries.value.length > maxEntriesValue.value)
entries.value.splice(0, entries.value.length - maxEntriesValue.value)
})
onUnmounted(() => {
for (const cleanup of cleanupFns)
cleanup()
})
</script>
<template>
<div :class="['flex', 'flex-col', 'gap-6']">
<Callout label="Context Flow">
Inspect incoming context updates (server + broadcast) and outgoing chat hooks in real time. Use this to verify
how plugin context (e.g. VSCode coding context) travels into the chat pipeline and out to server events.
</Callout>
<div :class="['grid', 'gap-6', 'lg:grid-cols-[360px_1fr]']">
<ContextFlowFilters
v-model:direction-filter="directionFilter"
v-model:show-incoming="showIncoming"
v-model:show-outgoing="showOutgoing"
v-model:show-server="showServer"
v-model:show-broadcast="showBroadcast"
v-model:show-chat="showChat"
v-model:show-devtools="showDevtools"
v-model:max-entries="maxEntries"
@clear="clearEntries"
/>
<div :class="['flex', 'flex-col', 'gap-2']">
<ContextFlowActions
v-model:test-strategy="testStrategy"
v-model:test-payload="testPayload"
v-model:test-spark-notify-payload="testSparkNotifyPayload"
@send-context-update="sendTestContextUpdate"
@send-spark-notify="sendTestSparkNotify"
/>
<ContextFlowStream
v-model:filter-text="filterText"
:entries="filteredEntries"
:get-spark-notify-state="getSparkNotifyEntryState"
/>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
</route>