11<script setup lang="ts">
2+ import type { WebSocketBaseEvent , WebSocketEvents } from ' @proj-airi/server-sdk'
23import type { ChatStreamEvent , ContextMessage } from ' @proj-airi/stage-ui/types/chat'
34
45import { ContextUpdateStrategy } from ' @proj-airi/server-sdk'
56import { Callout , Section } from ' @proj-airi/stage-ui/components'
7+ import { useCharacterOrchestratorStore } from ' @proj-airi/stage-ui/stores/character-orchestrator'
68import { CHAT_STREAM_CHANNEL_NAME , CONTEXT_CHANNEL_NAME , useChatStore } from ' @proj-airi/stage-ui/stores/chat'
79import { useModsServerChannelStore } from ' @proj-airi/stage-ui/stores/mods/api/channel-server'
810import { Button , FieldCheckbox , FieldInput , FieldTextArea , Input , SelectTab } from ' @proj-airi/ui'
911import { useBroadcastChannel } from ' @vueuse/core'
12+ import { nanoid } from ' nanoid'
1013import { computed , nextTick , onMounted , onUnmounted , ref , watch } from ' vue'
1114
1215type FlowDirection = ' incoming' | ' outgoing'
@@ -24,6 +27,7 @@ interface FlowEntry {
2427}
2528
2629const chatStore = useChatStore ()
30+ const characterOrchestratorStore = useCharacterOrchestratorStore ()
2731const serverChannelStore = useModsServerChannelStore ()
2832
2933const entries = ref <FlowEntry []>([])
@@ -39,6 +43,17 @@ const maxEntries = ref('200')
3943const testPayload = ref (' {"type":"coding:context","data":{"file":{"path":"README.md"}}}' )
4044const testStrategy = ref <ContextUpdateStrategy >(ContextUpdateStrategy .ReplaceSelf )
4145
46+ const testSparkNotifyPayload = ref (JSON .stringify ({
47+ kind: ' ping' ,
48+ urgency: ' immediate' ,
49+ headline: ' Devtools spark:notify test' ,
50+ note: ' Triggered from Context Flow devtools' ,
51+ destinations: [' character' ],
52+ payload: {
53+ message: ' Hello from Context Flow devtools' ,
54+ },
55+ }, null , 2 ))
56+
4257const streamContainer = ref <HTMLDivElement >()
4358
4459const directionOptions = [
@@ -386,6 +401,90 @@ function sendTestContextUpdate() {
386401 })
387402}
388403
404+ async function sendTestSparkNotify() {
405+ const raw = testSparkNotifyPayload .value .trim ()
406+ if (! raw )
407+ return
408+
409+ let parsed: any
410+ try {
411+ parsed = JSON .parse (raw )
412+ }
413+ catch {
414+ pushEntry ({
415+ direction: ' incoming' ,
416+ channel: ' devtools' ,
417+ type: ' spark:notify' ,
418+ summary: ' invalid json' ,
419+ payload: { raw },
420+ })
421+ return
422+ }
423+
424+ const destinations = Array .isArray (parsed ?.destinations ) ? parsed .destinations .filter ((d : unknown ) => typeof d === ' string' ) : []
425+ if (! parsed ?.headline || ! destinations .length ) {
426+ pushEntry ({
427+ direction: ' incoming' ,
428+ channel: ' devtools' ,
429+ type: ' spark:notify' ,
430+ summary: ' missing required fields (headline, destinations[])' ,
431+ payload: parsed ,
432+ })
433+ return
434+ }
435+
436+ // TODO(@nekomeowww): improve server event, support to have zod or valibot schema validation for better cross runtime handling
437+ const notify = {
438+ id: typeof parsed .id === ' string' && parsed .id ? parsed .id : nanoid (),
439+ eventId: typeof parsed .eventId === ' string' && parsed .eventId ? parsed .eventId : nanoid (),
440+ lane: typeof parsed .lane === ' string' ? parsed .lane : undefined ,
441+ kind: parsed .kind === ' alarm' || parsed .kind === ' ping' || parsed .kind === ' reminder' ? parsed .kind : ' ping' ,
442+ urgency: parsed .urgency === ' immediate' || parsed .urgency === ' soon' || parsed .urgency === ' later' ? parsed .urgency : ' immediate' ,
443+ headline: String (parsed .headline ),
444+ note: typeof parsed .note === ' string' ? parsed .note : undefined ,
445+ payload: parsed .payload && typeof parsed .payload === ' object' ? parsed .payload : undefined ,
446+ ttlMs: typeof parsed .ttlMs === ' number' ? parsed .ttlMs : undefined ,
447+ requiresAck: typeof parsed .requiresAck === ' boolean' ? parsed .requiresAck : undefined ,
448+ destinations ,
449+ metadata: parsed .metadata && typeof parsed .metadata === ' object' ? parsed .metadata : undefined ,
450+ }
451+
452+ const simulatedEvent: WebSocketBaseEvent <' spark:notify' , WebSocketEvents [' spark:notify' ]> = {
453+ type: ' spark:notify' ,
454+ source: ' devtools' ,
455+ data: notify ,
456+ }
457+
458+ pushEntry ({
459+ direction: ' incoming' ,
460+ channel: ' server' ,
461+ type: ' spark:notify' ,
462+ summary: summarizeServerEvent (simulatedEvent as any ),
463+ payload: simulatedEvent ,
464+ })
465+
466+ try {
467+ const result = await characterOrchestratorStore .handleSparkNotify (simulatedEvent )
468+ if (result ?.commands ?.length ) {
469+ for (const command of result .commands ) {
470+ serverChannelStore .send ({
471+ type: ' spark:command' ,
472+ data: command ,
473+ })
474+ }
475+ }
476+ }
477+ catch (error ) {
478+ pushEntry ({
479+ direction: ' incoming' ,
480+ channel: ' devtools' ,
481+ type: ' spark:notify' ,
482+ summary: ` handler error: ${String (error )} ` ,
483+ payload: simulatedEvent ,
484+ })
485+ }
486+ }
487+
389488const { data : incomingContext } = useBroadcastChannel <ContextMessage , ContextMessage >({
390489 name: CONTEXT_CHANNEL_NAME ,
391490})
@@ -667,6 +766,19 @@ onUnmounted(() => {
667766 </div >
668767 </div >
669768 </Section >
769+ <Section title =" Simulate incoming" icon =" i-solar:plain-2-bold-duotone" inner-class =" gap-3" :expand =" false" >
770+ <div :class =" ['mt-4', 'border-t', 'border-neutral-200/70', 'pt-4', 'dark:border-neutral-800/80']" >
771+ <FieldTextArea
772+ v-model =" testSparkNotifyPayload"
773+ label =" spark:notify"
774+ description =" Raw JSON payload for spark:notify. Required: headline, destinations[]. id/eventId will be auto-filled if missing."
775+ :input-class =" ['font-mono', 'min-h-44']"
776+ />
777+ <div :class =" ['flex', 'justify-end']" >
778+ <Button label =" Send spark:notify" icon =" i-solar:bell-bing-bold-duotone" size =" sm" @click =" sendTestSparkNotify" />
779+ </div >
780+ </div >
781+ </Section >
670782
671783 <div :class =" ['flex']" >
672784 <Input
0 commit comments