refactor(stage-pages): split context flow into small components
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+60
@@ -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>
|
||||
+79
@@ -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>
|
||||
+73
@@ -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>
|
||||
+42
@@ -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>
|
||||
+121
@@ -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>
|
||||
+64
@@ -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>
|
||||
+37
@@ -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')
|
||||
})
|
||||
})
|
||||
+244
@@ -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>
|
||||
Reference in New Issue
Block a user