From 3a71d70bcf3c6086c3c074e17ca0d04481bf5d68 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Mon, 29 Dec 2025 23:56:27 +0800 Subject: [PATCH] chore(stage-pages): improved ui/ux for context-flow devtools --- .../src/pages/devtools/context-flow.vue | 331 ++++++++++++------ .../src/stores/mods/api/channel-server.ts | 34 +- 2 files changed, 251 insertions(+), 114 deletions(-) diff --git a/packages/stage-pages/src/pages/devtools/context-flow.vue b/packages/stage-pages/src/pages/devtools/context-flow.vue index f83630a6a..4f7b73a10 100644 --- a/packages/stage-pages/src/pages/devtools/context-flow.vue +++ b/packages/stage-pages/src/pages/devtools/context-flow.vue @@ -2,7 +2,7 @@ import type { ChatStreamEvent, ContextMessage } from '@proj-airi/stage-ui/types/chat' import { ContextUpdateStrategy } from '@proj-airi/server-sdk' -import { Callout, Collapsable, Section } from '@proj-airi/stage-ui/components' +import { Callout, Section } from '@proj-airi/stage-ui/components' 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 { Button, FieldCheckbox, FieldInput, FieldTextArea, Input, SelectTab } from '@proj-airi/ui' @@ -27,12 +27,12 @@ const chatStore = useChatStore() const serverChannelStore = useModsServerChannelStore() const entries = ref([]) -const captureBroadcast = ref(true) -const captureChatHooks = ref(true) -const captureServerUpdates = ref(true) -const autoScroll = ref(true) 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') @@ -64,17 +64,26 @@ const maxEntriesValue = computed(() => { const filteredEntries = computed(() => { const query = filterText.value.trim().toLowerCase() - return entries.value.filter((entry) => { + 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) { @@ -92,7 +101,36 @@ function truncateText(value: string, limit = 160) { return `${value.slice(0, limit)}...` } -function summarizeContextUpdate(update: { text?: string, content?: unknown }) { +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 | undefined + if (!payload) + return undefined + return payload.data ?? payload +} + +function getEventSource(entry: FlowEntry) { + const payload = entry.payload as Record | 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)}"`) @@ -110,6 +148,9 @@ function summarizeContextUpdate(update: { text?: string, content?: unknown }) { })() summaryParts.push(`content="${truncateText(contentText, 120)}"`) } + if (update.destinations !== undefined) { + summaryParts.push(`destinations="${truncateText(formatDestinations(update.destinations), 120)}"`) + } return summaryParts.join(' ') } @@ -134,17 +175,13 @@ function formatPreviewValue(value: unknown) { } function getContextUpdatePreview(entry: FlowEntry) { - const payload = entry.payload as Record | undefined - if (!payload) - return null - const candidate = entry.type === 'context:update' && payload.data - ? payload.data - : payload - if (!candidate || (candidate.text === undefined && candidate.content === undefined)) + const candidate = getPayloadData(entry) as Record | 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, } } @@ -158,10 +195,24 @@ function buildPreviewItems(entry: FlowEntry): PreviewItem[] { 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 = entry.payload as Record | undefined + const payload = getPayloadData(entry) as Record | 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) }) } @@ -174,6 +225,15 @@ function buildPreviewItems(entry: FlowEntry): PreviewItem[] { 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) }) } @@ -181,6 +241,35 @@ function buildPreviewItems(entry: FlowEntry): PreviewItem[] { return items } +function summarizeServerEvent(event: { type: string, data: Record }) { + 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) { const payloadText = typeof entry.payload === 'string' ? entry.payload @@ -239,20 +328,24 @@ function formatPayload(payload: unknown) { function directionBadgeClasses(direction: FlowDirection) { if (direction === 'incoming') { return [ - 'bg-emerald-500/15', - 'text-emerald-600', - 'dark:text-emerald-300', - 'border-emerald-500/30', + 'bg-complementary-500/15', + 'text-complementary-600', + 'dark:text-complementary-300', + 'border-complementary-500/30', ] } return [ - 'bg-sky-500/15', - 'text-sky-600', - 'dark:text-sky-300', - 'border-sky-500/30', + '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': @@ -266,6 +359,10 @@ function channelBadgeClasses(channel: FlowChannel) { } } +function sourceBadgeClasses() { + return ['bg-neutral-400/15', 'text-neutral-600', 'dark:text-neutral-300', 'border-neutral-500/30'] +} + function clearEntries() { entries.value = [] } @@ -300,9 +397,6 @@ const cleanupFns: Array<() => void> = [] onMounted(() => { cleanupFns.push(serverChannelStore.onContextUpdate((event) => { - if (!captureServerUpdates.value) - return - pushEntry({ direction: 'incoming', channel: 'server', @@ -316,10 +410,35 @@ onMounted(() => { }) })) + 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) => { + pushEntry({ + direction: 'incoming', + channel: 'server', + type: event.type, + summary: summarizeServerEvent(event as any), + payload: event, + }) + })) + } + cleanupFns.push( chatStore.onBeforeMessageComposed(async (message, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -329,8 +448,6 @@ onMounted(() => { }) }), chatStore.onAfterMessageComposed(async (message, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -340,8 +457,6 @@ onMounted(() => { }) }), chatStore.onBeforeSend(async (message, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -351,8 +466,6 @@ onMounted(() => { }) }), chatStore.onAfterSend(async (message, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -362,8 +475,6 @@ onMounted(() => { }) }), chatStore.onTokenLiteral(async (literal, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -373,8 +484,6 @@ onMounted(() => { }) }), chatStore.onTokenSpecial(async (special, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -384,8 +493,6 @@ onMounted(() => { }) }), chatStore.onStreamEnd(async (context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -395,8 +502,6 @@ onMounted(() => { }) }), chatStore.onAssistantResponseEnd(async (message, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -406,8 +511,6 @@ onMounted(() => { }) }), chatStore.onAssistantMessage(async (message, messageText, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -417,8 +520,6 @@ onMounted(() => { }) }), chatStore.onChatTurnComplete(async (chat, context) => { - if (!captureChatHooks.value) - return pushEntry({ direction: 'outgoing', channel: 'chat', @@ -431,7 +532,7 @@ onMounted(() => { }) watch(incomingContext, (event) => { - if (!event || !captureBroadcast.value) + if (!event) return pushEntry({ @@ -448,7 +549,7 @@ watch(incomingContext, (event) => { }) watch(incomingStreamEvent, (event) => { - if (!event || !captureBroadcast.value) + if (!event) return pushEntry({ @@ -467,11 +568,9 @@ watch(incomingStreamEvent, (event) => { }) watch(() => entries.value.length, async () => { - if (!autoScroll.value) - return await nextTick() if (streamContainer.value) - streamContainer.value.scrollTop = streamContainer.value.scrollHeight + streamContainer.value.scrollTop = 0 }) watch(maxEntriesValue, () => { @@ -496,13 +595,50 @@ onUnmounted(() => {
- Capture toggles + Filters
- - - - +
+
+ Direction +
+ +
+
+
+ Visibility +
+
+ + +
+
+
+
+ Channels +
+
+ + + + +
+
+
+ +
+
+
@@ -527,56 +663,17 @@ onUnmounted(() => { :input-class="['font-mono', 'min-h-32']" />
-
-
-
-
- Event stream -
+
+
- - -
-
-
- Direction -
- -
-
-
- Visibility -
-
- - -
-
-
- -
-
-
-
-
-
+
{ 'dark:bg-neutral-950/60', ]" > -
- - {{ entry.direction.toUpperCase() }} - - - {{ entry.channel }} - - +
+
+ + + + + {{ entry.channel }} + + + {{ getEventSource(entry) }} + + + {{ entry.type }} + +
+ {{ formatTimestamp(entry.timestamp) }} - - {{ entry.type }} -
diff --git a/packages/stage-ui/src/stores/mods/api/channel-server.ts b/packages/stage-ui/src/stores/mods/api/channel-server.ts index 93c8bc9dd..2a961766d 100644 --- a/packages/stage-ui/src/stores/mods/api/channel-server.ts +++ b/packages/stage-ui/src/stores/mods/api/channel-server.ts @@ -12,6 +12,23 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se const initializing = ref | null>(null) const pendingSend = ref>([]) + const basePossibleEvents: Array = [ + 'context:update', + 'error', + 'module:announce', + 'module:configure', + 'module:authenticated', + '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', + 'ui:configure', + ] + function initialize(options?: { token?: string, possibleEvents?: Array }) { if (connected.value && client.value) return Promise.resolve() @@ -19,7 +36,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se return initializing.value const possibleEvents = Array.from(new Set([ - 'ui:configure', + ...basePossibleEvents, ...(options?.possibleEvents ?? []), ])) @@ -98,6 +115,20 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se } } + function onEvent( + type: E, + callback: (event: WebSocketBaseEvent) => void | Promise, + ) { + if (!client.value && !initializing.value) + void initialize() + + client.value?.onEvent(type, callback as any) + + return () => { + client.value?.offEvent(type, callback as any) + } + } + function sendContextUpdate(message: Omit & Partial>) { const id = nanoid() send({ type: 'context:update', data: { id, contextId: id, ...message } }) @@ -119,6 +150,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se send, sendContextUpdate, onContextUpdate, + onEvent, dispose, } })