feat(stage-*): devtool for websocket (#932)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
LemonNeko
2026-01-11 02:23:18 +08:00
committed by GitHub
co-authored by gemini-code-assist[bot] autofix-ci[bot]
parent a7d57935b6
commit a7d7c1631e
10 changed files with 282 additions and 12 deletions
@@ -80,6 +80,12 @@ const menu = computed(() => [
icon: 'i-solar:sledgehammer-bold-duotone',
to: '/devtools/notifications',
},
{
title: 'WebSocket Inspector',
description: 'Inspect raw WebSocket traffic',
icon: 'i-solar:transfer-horizontal-bold-duotone',
to: '/devtools/websocket-inspector',
},
])
</script>
@@ -59,6 +59,12 @@ const menu = computed(() => [
icon: 'i-solar:chart-bold-duotone',
to: '/devtools/beat-sync',
},
{
title: 'WebSocket Inspector',
description: 'Inspect raw WebSocket traffic',
icon: 'i-solar:transfer-horizontal-bold-duotone',
to: '/devtools/websocket-inspector',
},
])
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
@@ -68,6 +68,12 @@ const menu = computed(() => [
icon: 'i-solar:chat-square-call-bold-duotone',
to: '/devtools/context-flow',
},
{
title: 'WebSocket Inspector',
description: 'Inspect raw WebSocket traffic',
icon: 'i-solar:transfer-horizontal-bold-duotone',
to: '/devtools/websocket-inspector',
},
{
title: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.title'),
description: t('settings.pages.system.sections.section.developer.sections.section.use-magic-keys.description'),
+11 -2
View File
@@ -30,6 +30,8 @@ export interface ClientOptions<C = undefined> {
autoConnect?: boolean
autoReconnect?: boolean
maxReconnectAttempts?: number
onAnyMessage?: (data: WebSocketEvent<C>) => void
onAnySend?: (data: WebSocketEvent<C>) => void
}
function createInstanceId() {
@@ -60,6 +62,8 @@ export class Client<C = undefined> {
this.opts = {
url: 'ws://localhost:6121/ws',
onAnyMessage: () => {},
onAnySend: () => {},
possibleEvents: [],
onError: () => {},
onClose: () => {},
@@ -246,6 +250,7 @@ export class Client<C = undefined> {
private async handleMessage(event: MessageEvent) {
try {
const data = JSON.parse(event.data as string) as WebSocketEvent<C>
this.opts.onAnyMessage?.(data)
const listeners = this.eventListeners.get(data.type)
if (!listeners?.size) {
return
@@ -299,11 +304,15 @@ export class Client<C = undefined> {
send(data: WebSocketEventOptionalSource<C>): void {
if (this.websocket && this.connected) {
this.websocket.send(JSON.stringify({
const payload = {
source: this.opts.name as WebSocketEventSource | string,
metadata: { source: this.identity },
...data,
} as WebSocketEvent<C>))
} as WebSocketEvent<C>
this.opts.onAnySend?.(payload)
this.websocket.send(JSON.stringify(payload))
}
}
@@ -114,6 +114,10 @@ const routeHeaderMetadataMap = computed(() => {
subtitle: t('tamagotchi.settings.devtools.title'),
title: t('tamagotchi.settings.devtools.pages.context-flow.title'),
},
'/devtools/websocket-inspector': {
subtitle: t('tamagotchi.settings.devtools.title'),
title: 'WebSocket Inspector',
},
'/devtools/performance-visualizer': {
subtitle: t('settings.title'),
title: t('settings.pages.system.sections.section.developer.sections.section.performance-visualizer.title'),
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { useWebSocketInspectorStore } from '@proj-airi/stage-ui/stores/devtools/websocket-inspector'
import { Button, FieldCheckbox, Input } from '@proj-airi/ui'
import { computed, nextTick, ref, watch } from 'vue'
const store = useWebSocketInspectorStore()
const filter = ref('')
const showIncoming = ref(true)
const showOutgoing = ref(true)
const showHeartbeats = ref(true)
const streamContainer = ref<HTMLDivElement>()
const showingDetails = ref<string>('')
const filteredHistory = computed(() => {
return store.history.filter((item) => {
if (!showIncoming.value && item.direction === 'incoming')
return false
if (!showOutgoing.value && item.direction === 'outgoing')
return false
if (!showHeartbeats.value && item.event.type === 'transport:connection:heartbeat')
return false
if (filter.value && !JSON.stringify(item.event).toLowerCase().includes(filter.value.toLowerCase()))
return false
return true
})
})
function formatTime(ts: number) {
return new Date(ts).toLocaleTimeString()
}
async function scrollToTop() {
await nextTick()
if (streamContainer.value)
streamContainer.value.scrollTop = 0
}
watch(() => filteredHistory.value.length, scrollToTop)
const directionBadgeClassMap: Record<'incoming' | 'outgoing', string[]> = {
incoming: [
'bg-pink-100 dark:bg-pink-900',
'text-pink-600',
'dark:text-pink-300',
'border-pink-300 dark:border-pink-900',
],
outgoing: [
'bg-blue-100 dark:bg-blue-900',
'text-blue-600',
'dark:text-blue-300',
'border-blue-300 dark:border-blue-900',
],
}
const directionIconClassMap: Record<'incoming' | 'outgoing', string> = {
incoming: 'i-solar:arrow-down-linear',
outgoing: 'i-solar:arrow-up-linear',
}
function directionBadgeClasses(direction: 'incoming' | 'outgoing') {
return directionBadgeClassMap[direction]
}
function directionIconClass(direction: 'incoming' | 'outgoing') {
return directionIconClassMap[direction]
}
const cardClassMap: Record<'incoming' | 'outgoing', string[]> = {
incoming: [
'bg-pink-50',
'dark:bg-pink-950',
'border-pink-300',
'text-pink-700',
'dark:border-pink-800',
'dark:text-pink-100',
],
outgoing: [
'bg-blue-50',
'dark:bg-blue-950',
'border-blue-300',
'text-blue-700',
'dark:border-blue-800',
'dark:text-blue-100',
],
}
function cardClasses(direction: 'incoming' | 'outgoing') {
return cardClassMap[direction]
}
const payloadClassMap: Record<'incoming' | 'outgoing', string[]> = {
incoming: [
'bg-pink-100',
'border-pink-300 border-1 border-solid',
'dark:bg-pink-900',
'dark:border-pink-700',
],
outgoing: [
'bg-blue-100',
'border-blue-300 border-1 border-solid',
'dark:bg-blue-900',
'dark:border-blue-700',
],
}
function payloadClasses(direction: 'incoming' | 'outgoing') {
return payloadClassMap[direction]
}
</script>
<template>
<div class="h-full flex flex-col gap-4 overflow-hidden p-4">
<!-- Header / Filters -->
<div class="flex flex-col gap-4 rounded-xl bg-neutral-50 p-4 dark:bg-[rgba(0,0,0,0.3)]">
<div class="flex items-center gap-2">
<FieldCheckbox v-model="showIncoming" label="Incoming" />
<FieldCheckbox v-model="showOutgoing" label="Outgoing" />
<FieldCheckbox v-model="showHeartbeats" label="Heartbeats" />
</div>
<div class="flex gap-2">
<Input
v-model="filter"
placeholder="Filter payload..."
class="w-64"
/>
<Button
label="Clear"
icon="i-solar:trash-bin-trash-bold-duotone"
size="sm"
variant="ghost"
@click="store.clear()"
/>
<div class="flex flex-shrink-0 items-center text-xs">
{{ filteredHistory.length }} / {{ store.history.length }}
</div>
</div>
</div>
<!-- Stream -->
<div
ref="streamContainer"
class="flex-1 overflow-y-auto rounded-xl bg-white/70 dark:bg-neutral-950/50"
>
<div
v-if="filteredHistory.length === 0"
class="h-full w-full flex justify-center p-3 text-sm"
>
No messages found.
</div>
<div v-else class="grid gap-3">
<div
v-for="item in filteredHistory"
:key="item.id"
class="border rounded-xl p-4 transition-colors"
:class="cardClasses(item.direction)"
>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span :class="['rounded-full', 'border', 'px-2', 'py-0.5', 'text-xs', 'flex', 'items-center', 'justify-center', ...directionBadgeClasses(item.direction)]">
<span :class="['size-3.5', directionIconClass(item.direction)]" :aria-label="item.direction" />
<span class="ml-1 font-bold tracking-wider uppercase">{{ item.direction }}</span>
</span>
<span class="font-semibold">
{{ item.event.type }}
</span>
</div>
<span class="text-sm font-mono">
{{ formatTime(item.timestamp) }}
</span>
</div>
<details class="group mt-2" :open="showingDetails === item.id">
<summary class="cursor-pointer select-none text-sm font-medium" @click="showingDetails = showingDetails === item.id ? '' : item.id">
Payload
</summary>
<pre
class="mt-2 w-full overflow-auto whitespace-pre-wrap rounded-lg p-3 text-sm"
:class="payloadClasses(item.direction)"
>{{ JSON.stringify(item.event, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
</route>
@@ -0,0 +1,46 @@
import type { WebSocketEvent } from '@proj-airi/server-sdk'
import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface WebSocketHistoryItem {
id: string
timestamp: number
direction: 'incoming' | 'outgoing'
event: WebSocketEvent
}
export const useWebSocketInspectorStore = defineStore('devtools:websocket-inspector', () => {
const history = ref<WebSocketHistoryItem[]>([])
const isEnabled = ref(true)
const maxHistory = ref(1000)
function add(direction: 'incoming' | 'outgoing', event: WebSocketEvent) {
if (!isEnabled.value)
return
history.value.unshift({
id: nanoid(),
timestamp: Date.now(),
direction,
event,
})
if (history.value.length > maxHistory.value) {
history.value.pop()
}
}
function clear() {
history.value = []
}
return {
history,
isEnabled,
maxHistory,
add,
clear,
}
})
@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useWebSocketInspectorStore } from '../../devtools/websocket-inspector'
export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:server', () => {
const connected = ref(false)
const client = ref<Client>()
@@ -48,6 +50,12 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
url: import.meta.env.VITE_AIRI_WS_URL || 'ws://localhost:6121/ws',
token: options?.token,
possibleEvents,
onAnyMessage: (event) => {
useWebSocketInspectorStore().add('incoming', event)
},
onAnySend: (event) => {
useWebSocketInspectorStore().add('outgoing', event)
},
onError: (error) => {
connected.value = false
initializing.value = null
@@ -6,7 +6,7 @@ import { isStageTamagotchi, isStageWeb } from '@proj-airi/stage-shared'
import { useBroadcastChannel } from '@vueuse/core'
import { Mutex } from 'es-toolkit'
import { defineStore } from 'pinia'
import { ref, watch, toRaw } from 'vue'
import { ref, toRaw, watch } from 'vue'
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME, useChatStore } from '../../chat'
import { useModsServerChannelStore } from './channel-server'
+3 -9
View File
@@ -2919,7 +2919,7 @@ importers:
version: 66.5.11
'@wxt-dev/module-vue':
specifier: ^1.0.3
version: 1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vue-tsc:
specifier: ^3.1.8
version: 3.2.1(typescript@5.9.3)
@@ -22215,12 +22215,6 @@ snapshots:
vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.25(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.25(typescript@5.9.3)
'@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
dependencies:
'@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)
@@ -22967,9 +22961,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@wxt-dev/module-vue@1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
'@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- vite