feat(stage-*): performance overlay & markdown stress test (#838)
--------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
co-authored by
autofix-ci[bot]
Neko
parent
1f58faafa2
commit
9005b9f430
@@ -20,6 +20,7 @@ import { setupAboutWindowReusable } from './windows/about'
|
||||
import { setupBeatSync } from './windows/beat-sync'
|
||||
import { setupCaptionWindowManager } from './windows/caption'
|
||||
import { setupChatWindowReusableFunc } from './windows/chat'
|
||||
import { setupDevtoolsWindow } from './windows/devtools'
|
||||
import { setupMainWindow } from './windows/main'
|
||||
import { setupNoticeWindowManager } from './windows/notice'
|
||||
import { setupSettingsWindowReusableFunc } from './windows/settings'
|
||||
@@ -82,6 +83,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
// BeatSync will create a background window to capture and process audio.
|
||||
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
|
||||
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
|
||||
|
||||
const chatWindow = injeca.provide('windows:chat', {
|
||||
dependsOn: { widgetsManager },
|
||||
@@ -89,7 +91,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const settingsWindow = injeca.provide('windows:settings', {
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater },
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsMarkdownStressWindow },
|
||||
build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import { BrowserWindow, shell } from 'electron'
|
||||
|
||||
import icon from '../../../../resources/icon.png?asset'
|
||||
|
||||
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
|
||||
import { createReusableWindow } from '../../libs/electron/window-manager'
|
||||
|
||||
export interface DevtoolsWindowManager {
|
||||
openWindow: (route?: string) => Promise<BrowserWindow>
|
||||
getWindow: () => Promise<BrowserWindow>
|
||||
}
|
||||
|
||||
export function setupDevtoolsWindow(): DevtoolsWindowManager {
|
||||
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
|
||||
const defaultRoute = '/devtools/markdown-stress'
|
||||
let currentRoute = defaultRoute
|
||||
|
||||
const reusable = createReusableWindow(async () => {
|
||||
const window = new BrowserWindow({
|
||||
title: 'Devtools',
|
||||
width: 1020,
|
||||
height: 720,
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
show: false,
|
||||
icon,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.mjs'),
|
||||
// Preload exposes Electron APIs and needs Node access.
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
|
||||
window.on('ready-to-show', () => window.show())
|
||||
window.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
await load(window, withHashRoute(rendererBase, currentRoute))
|
||||
return window
|
||||
})
|
||||
|
||||
async function openWindow(route?: string) {
|
||||
if (route)
|
||||
currentRoute = route
|
||||
|
||||
const window = await reusable.getWindow()
|
||||
const targetRoute = route ?? currentRoute
|
||||
const url = withHashRoute(rendererBase, targetRoute)
|
||||
|
||||
// If the route changes while the window is open, reload to that route.
|
||||
const currentUrl = window.webContents.getURL()
|
||||
if (!currentUrl.includes(`#${targetRoute}`)) {
|
||||
await load(window, url)
|
||||
}
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
return {
|
||||
openWindow,
|
||||
getWindow: reusable.getWindow,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
||||
import type { DevtoolsWindowManager } from '../devtools'
|
||||
import type { WidgetsWindowManager } from '../widgets'
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -14,6 +15,7 @@ import { setupSettingsWindowInvokes } from './rpc/index.electron'
|
||||
export function setupSettingsWindowReusableFunc(params: {
|
||||
widgetsManager: WidgetsWindowManager
|
||||
autoUpdater: AutoUpdater
|
||||
devtoolsMarkdownStressWindow: DevtoolsWindowManager
|
||||
onWindowCreated?: (window: BrowserWindow) => void
|
||||
}) {
|
||||
return createReusableWindow(async () => {
|
||||
@@ -40,7 +42,12 @@ export function setupSettingsWindowReusableFunc(params: {
|
||||
})
|
||||
|
||||
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/settings'))
|
||||
await setupSettingsWindowInvokes({ settingsWindow: window, widgetsManager: params.widgetsManager, autoUpdater: params.autoUpdater })
|
||||
await setupSettingsWindowInvokes({
|
||||
settingsWindow: window,
|
||||
widgetsManager: params.widgetsManager,
|
||||
autoUpdater: params.autoUpdater,
|
||||
devtoolsMarkdownStressWindow: params.devtoolsMarkdownStressWindow,
|
||||
})
|
||||
|
||||
return window
|
||||
}).getWindow
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
||||
import type { DevtoolsWindowManager } from '../../devtools'
|
||||
import type { WidgetsWindowManager } from '../../widgets'
|
||||
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { electronOpenSettingsDevtools } from '../../../../shared/eventa'
|
||||
import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa'
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
import { createAutoUpdaterService, createScreenService, createWindowService } from '../../../services/electron'
|
||||
|
||||
export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow, widgetsManager: WidgetsWindowManager, autoUpdater: AutoUpdater }) {
|
||||
export async function setupSettingsWindowInvokes(params: {
|
||||
settingsWindow: BrowserWindow
|
||||
widgetsManager: WidgetsWindowManager
|
||||
autoUpdater: AutoUpdater
|
||||
devtoolsMarkdownStressWindow: DevtoolsWindowManager
|
||||
}) {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
// manage events within eventa's context system.
|
||||
@@ -25,4 +31,7 @@ export async function setupSettingsWindowInvokes(params: { settingsWindow: Brows
|
||||
createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater })
|
||||
|
||||
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
|
||||
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {
|
||||
await params.devtoolsMarkdownStressWindow.openWindow(payload?.route)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/c
|
||||
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
import { usePerfTracerBridgeStore } from '@proj-airi/stage-ui/stores/perf-tracer-bridge'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -31,6 +32,7 @@ const cardStore = useAiriCardStore()
|
||||
const serverChannelStore = useModsServerChannelStore()
|
||||
const characterOrchestratorStore = useCharacterOrchestratorStore()
|
||||
const analyticsStore = useSharedAnalyticsStore()
|
||||
usePerfTracerBridgeStore()
|
||||
|
||||
watch(language, () => {
|
||||
i18n.locale.value = language.value
|
||||
|
||||
@@ -120,6 +120,10 @@ const routeHeaderMetadataMap = computed(() => {
|
||||
subtitle: t('tamagotchi.settings.devtools.title'),
|
||||
title: t('tamagotchi.settings.devtools.pages.widgets-calling.title'),
|
||||
},
|
||||
'/devtools/markdown-stress': {
|
||||
subtitle: t('tamagotchi.settings.devtools.title'),
|
||||
title: t('tamagotchi.settings.devtools.pages.markdown-stress.title'),
|
||||
},
|
||||
'/devtools/context-flow': {
|
||||
subtitle: t('tamagotchi.settings.devtools.title'),
|
||||
title: t('tamagotchi.settings.devtools.pages.context-flow.title'),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
// NOTICE: This view is defined for parity but not used by stage-tamagotchi yet.
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// TODO: Desktop lag overlay/parity with web implementation.
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col gap-3" pb-6>
|
||||
<div text="lg neutral-100">
|
||||
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.title') }}
|
||||
</div>
|
||||
<div text="sm neutral-400" flex="~ col gap-1">
|
||||
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.description') }}</span>
|
||||
<span>{{ t('settings.wip.description') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
</route>
|
||||
@@ -4,7 +4,7 @@ import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { electronOpenMainDevtools } from '../../../../shared/eventa'
|
||||
import { electronOpenDevtoolsWindow, electronOpenMainDevtools } from '../../../../shared/eventa'
|
||||
import { useElectronEventaInvoke } from '../../../composables/electron-vueuse'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -62,6 +62,7 @@ const menu = computed(() => [
|
||||
])
|
||||
|
||||
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
|
||||
const openMarkdownStressWindow = useElectronEventaInvoke(electronOpenDevtoolsWindow)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -80,6 +81,20 @@ const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
|
||||
>
|
||||
{{ t('settings.pages.page.developers.open-devtools.button') }}
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
v-motion
|
||||
mb-2
|
||||
icon="i-solar:code-bold-duotone"
|
||||
:text="t('tamagotchi.settings.devtools.pages.markdown-stress.title')"
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + (19 * 10)"
|
||||
:delay="2 * 50"
|
||||
transition="all ease-in-out duration-250"
|
||||
@click="() => openMarkdownStressWindow({ route: '/devtools/markdown-stress' })"
|
||||
>
|
||||
{{ t('tamagotchi.settings.devtools.pages.markdown-stress.title') }}
|
||||
</ButtonBar>
|
||||
<CheckBar
|
||||
v-model="settings.disableTransitions"
|
||||
v-motion
|
||||
@@ -90,7 +105,7 @@ const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + (19 * 10)"
|
||||
:delay="2 * 50"
|
||||
:delay="3 * 50"
|
||||
transition="all ease-in-out duration-250"
|
||||
/>
|
||||
<CheckBar
|
||||
@@ -104,7 +119,7 @@ const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + (20 * 10)"
|
||||
:delay="3 * 50"
|
||||
:delay="4 * 50"
|
||||
transition="all ease-in-out duration-250"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const electronOpenMainDevtools = defineInvokeEventa('eventa:invoke:electr
|
||||
export const electronOpenSettings = defineInvokeEventa('eventa:invoke:electron:windows:settings:open')
|
||||
export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open')
|
||||
export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open')
|
||||
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:devtools:open')
|
||||
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
|
||||
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@proj-airi/model-driver-mediapipe": "workspace:^",
|
||||
"@proj-airi/server-sdk": "workspace:^",
|
||||
"@proj-airi/stage-layouts": "workspace:^",
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@proj-airi/stage-ui": "workspace:^",
|
||||
"@proj-airi/stage-ui-three": "workspace:^",
|
||||
"@proj-airi/stage-ui-three-performance-runtime": "workspace:^",
|
||||
|
||||
@@ -16,6 +16,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { toast, Toaster } from 'vue-sonner'
|
||||
|
||||
import PerformanceOverlay from './components/Devtools/PerformanceOverlay.vue'
|
||||
|
||||
import { usePWAStore } from './stores/pwa'
|
||||
|
||||
import 'vue-sonner/style.css'
|
||||
@@ -125,6 +127,8 @@ function handleSetupSkipped() {
|
||||
@configured="handleSetupConfigured"
|
||||
@skipped="handleSetupSkipped"
|
||||
/>
|
||||
|
||||
<PerformanceOverlay />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import type { LagMetric } from '../../stores/devtools-lag'
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
||||
|
||||
const store = useDevtoolsLagStore()
|
||||
const { enabled, buffers, recording } = storeToRefs(store)
|
||||
|
||||
const hovered = ref(false)
|
||||
|
||||
const metrics: Array<{ key: LagMetric, label: string, enabled: () => boolean }> = [
|
||||
{ key: 'fps', label: 'FPS', enabled: () => enabled.value.fps },
|
||||
{ key: 'frameDuration', label: 'Frame (ms)', enabled: () => enabled.value.frameDuration },
|
||||
{ key: 'longtask', label: 'Long task (ms)', enabled: () => enabled.value.longtask },
|
||||
{ key: 'memory', label: 'Memory (MB)', enabled: () => enabled.value.memory },
|
||||
]
|
||||
|
||||
const visibleMetrics = computed(() => metrics.filter(metric => metric.enabled()))
|
||||
const hasAnyEnabled = computed(() => visibleMetrics.value.length > 0)
|
||||
const metricStatsMap = computed<Record<LagMetric, ReturnType<typeof store.calcStats>>>(() => {
|
||||
const result = {} as Record<LagMetric, ReturnType<typeof store.calcStats>>
|
||||
for (const metric of metrics) {
|
||||
const values = buffers.value[metric.key].map(sample => sample.value)
|
||||
result[metric.key] = store.calcStats(values)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const metricsWithStats = computed(() => visibleMetrics.value.map(metric => ({
|
||||
...metric,
|
||||
stats: metricStatsMap.value[metric.key],
|
||||
})))
|
||||
|
||||
function formatValue(metric: string, value: number) {
|
||||
if (!Number.isFinite(value))
|
||||
return '--'
|
||||
|
||||
if (metric === 'memory')
|
||||
return `${(value / 1048576).toFixed(1)}`
|
||||
|
||||
if (metric === 'fps')
|
||||
return value.toFixed(0)
|
||||
|
||||
return value.toFixed(1)
|
||||
}
|
||||
|
||||
function barSeries(metric: LagMetric) {
|
||||
const values = buffers.value[metric].map(sample => sample.value)
|
||||
const histogram = store.buildHistogram(values, 20)
|
||||
const max = Math.max(1, ...histogram.map(bin => bin.count))
|
||||
return histogram.map(bin => ({
|
||||
width: `${100 / (histogram.length || 1)}%`,
|
||||
height: `${(bin.count / max) * 100}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
if (recording.value) {
|
||||
const snapshot = store.stopRecording()
|
||||
if (snapshot)
|
||||
store.exportCsv(snapshot)
|
||||
return
|
||||
}
|
||||
|
||||
store.startRecording()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasAnyEnabled"
|
||||
:style="{ opacity: hovered ? 1 : 0.3 }"
|
||||
class="fixed bottom-3 left-3 z-50"
|
||||
p-3
|
||||
flex="~ col gap-2"
|
||||
rounded="xl"
|
||||
bg="neutral-900/80"
|
||||
text="white sm"
|
||||
shadow="xl"
|
||||
transition="opacity 200ms ease"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<div flex="~ row items-center gap-2" justify-between>
|
||||
<div text="xs neutral-200" uppercase tracking="wide">
|
||||
Performance Overlay
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded bg-white/10 px-2 py-1 text-xs transition-colors hover:bg-white/20"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full"
|
||||
:class="recording ? 'bg-red-400' : 'bg-neutral-400'"
|
||||
/>
|
||||
<span>{{ recording ? 'Stop' : 'Record' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-for="metric in metricsWithStats" :key="metric.key" flex="~ col gap-1">
|
||||
<div flex="~ row items-center justify-between">
|
||||
<span text="xs neutral-100">{{ metric.label }}</span>
|
||||
<span text="xs neutral-300">
|
||||
<template v-if="metric.stats.latest">
|
||||
avg {{ formatValue(metric.key, metric.stats.avg) }}
|
||||
/
|
||||
p95 {{ formatValue(metric.key, metric.stats.p95) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
--
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-10 flex items-end gap-0.5 overflow-hidden rounded bg-white/5 px-1 py-1">
|
||||
<div
|
||||
v-for="(bar, index) in barSeries(metric.key)"
|
||||
:key="index"
|
||||
:style="{ width: bar.width, height: bar.height }"
|
||||
class="bg-white/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PerfTracer } from '@proj-airi/stage-shared'
|
||||
|
||||
interface LagEnabled {
|
||||
fps: boolean
|
||||
frameDuration: boolean
|
||||
longtask: boolean
|
||||
memory: boolean
|
||||
}
|
||||
|
||||
export function createLagSampler(tracer: PerfTracer) {
|
||||
let rafId: number | undefined
|
||||
let lastTs: number | undefined
|
||||
let longTaskObserver: PerformanceObserver | undefined
|
||||
let memoryTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function stopRaf() {
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = undefined
|
||||
}
|
||||
lastTs = undefined
|
||||
}
|
||||
|
||||
function startRaf() {
|
||||
stopRaf()
|
||||
|
||||
const loop = (ts: number) => {
|
||||
if (lastTs !== undefined) {
|
||||
const delta = ts - lastTs
|
||||
const fps = delta > 0 ? 1000 / delta : 0
|
||||
|
||||
tracer.emit({
|
||||
tracerId: 'lag',
|
||||
name: 'fps',
|
||||
ts,
|
||||
duration: fps,
|
||||
})
|
||||
|
||||
tracer.emit({
|
||||
tracerId: 'lag',
|
||||
name: 'frameDuration',
|
||||
ts,
|
||||
duration: delta,
|
||||
})
|
||||
}
|
||||
|
||||
lastTs = ts
|
||||
rafId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function stopLongTaskObserver() {
|
||||
longTaskObserver?.disconnect()
|
||||
longTaskObserver = undefined
|
||||
}
|
||||
|
||||
function startLongTaskObserver() {
|
||||
stopLongTaskObserver()
|
||||
if (!('PerformanceObserver' in window))
|
||||
return
|
||||
|
||||
try {
|
||||
longTaskObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
tracer.emit({
|
||||
tracerId: 'lag',
|
||||
name: 'longtask',
|
||||
ts: entry.startTime,
|
||||
duration: entry.duration,
|
||||
})
|
||||
}
|
||||
})
|
||||
longTaskObserver.observe({ type: 'longtask', buffered: true })
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[LagSampler] Failed to start longtask observer', error)
|
||||
}
|
||||
}
|
||||
|
||||
function stopMemoryTimer() {
|
||||
if (memoryTimer) {
|
||||
clearInterval(memoryTimer)
|
||||
memoryTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function startMemoryTimer() {
|
||||
stopMemoryTimer()
|
||||
const perfWithMemory = performance as Performance & { memory?: { usedJSHeapSize: number } }
|
||||
if (!perfWithMemory.memory)
|
||||
return
|
||||
|
||||
memoryTimer = setInterval(() => {
|
||||
tracer.emit({
|
||||
tracerId: 'lag',
|
||||
name: 'memory',
|
||||
ts: performance.now(),
|
||||
duration: perfWithMemory.memory?.usedJSHeapSize ?? 0,
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function start(enabled: LagEnabled) {
|
||||
stop()
|
||||
|
||||
if (enabled.fps || enabled.frameDuration)
|
||||
startRaf()
|
||||
|
||||
if (enabled.longtask)
|
||||
startLongTaskObserver()
|
||||
|
||||
if (enabled.memory)
|
||||
startMemoryTimer()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopRaf()
|
||||
stopLongTaskObserver()
|
||||
stopMemoryTimer()
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonBar, CheckBar } from '@proj-airi/stage-ui/components'
|
||||
import { useMagicKeys, whenever } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
||||
|
||||
const lagStore = useDevtoolsLagStore()
|
||||
const { enabled, lastRecording, recording } = storeToRefs(lagStore)
|
||||
|
||||
const recordingLabel = computed(() => recording.value ? 'Stop recording (max 60s)' : 'Start recording')
|
||||
const hasRecording = computed(() => !!lastRecording.value)
|
||||
const allEnabled = computed(() => enabled.value.fps && enabled.value.frameDuration && enabled.value.longtask && enabled.value.memory)
|
||||
|
||||
const magicKeys = useMagicKeys()
|
||||
whenever(magicKeys['ctrl+alt+l'], () => toggleAll(true))
|
||||
whenever(magicKeys['ctrl+alt+k'], () => toggleAll(false))
|
||||
|
||||
function toggleAll(on: boolean) {
|
||||
lagStore.toggleAll(on)
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
lagStore.exportCsv()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col gap-4" pb-6>
|
||||
<div flex="~ col gap-2">
|
||||
<div flex="~ row items-center gap-2">
|
||||
<CheckBar
|
||||
:model-value="allEnabled"
|
||||
icon-on="i-solar:sledgehammer-bold-duotone"
|
||||
icon-off="i-solar:sledgehammer-bold-duotone"
|
||||
text="Enable all metrics"
|
||||
description="Toggle all lag metrics (FPS, frame time, long task, memory)"
|
||||
@update:model-value="value => toggleAll(Boolean(value))"
|
||||
/>
|
||||
<ButtonBar
|
||||
:icon="recording ? 'i-solar:stop-circle-bold-duotone' : 'i-solar:recive-bold-duotone'"
|
||||
text="Recording"
|
||||
@click="recording ? lagStore.stopRecording() : lagStore.startRecording()"
|
||||
>
|
||||
{{ recordingLabel }}
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
icon="i-solar:export-bold-duotone"
|
||||
text="Export CSV"
|
||||
:disabled="!hasRecording"
|
||||
@click="exportCsv"
|
||||
>
|
||||
Export last recording
|
||||
</ButtonBar>
|
||||
</div>
|
||||
|
||||
<div flex="~ col gap-2">
|
||||
<CheckBar
|
||||
v-model="enabled.fps"
|
||||
icon-on="i-solar:activity-bold-duotone"
|
||||
icon-off="i-solar:activity-bold-duotone"
|
||||
text="FPS"
|
||||
description="Collect FPS histogram"
|
||||
/>
|
||||
<CheckBar
|
||||
v-model="enabled.frameDuration"
|
||||
icon-on="i-solar:chart-bold-duotone"
|
||||
icon-off="i-solar:chart-bold-duotone"
|
||||
text="Frame time (ms)"
|
||||
description="Collect frame duration histogram"
|
||||
/>
|
||||
<CheckBar
|
||||
v-model="enabled.longtask"
|
||||
icon-on="i-solar:timer-bold-duotone"
|
||||
icon-off="i-solar:timer-bold-duotone"
|
||||
text="Long tasks"
|
||||
description="PerformanceObserver('longtask')"
|
||||
/>
|
||||
<CheckBar
|
||||
v-model="enabled.memory"
|
||||
icon-on="i-solar:database-bold-duotone"
|
||||
icon-off="i-solar:database-bold-duotone"
|
||||
text="Memory"
|
||||
description="Sample performance.memory every second"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasRecording" flex="~ col gap-2" rounded="lg" border="1 dashed neutral-700" p-3>
|
||||
<div text="sm neutral-200">
|
||||
Last recording
|
||||
</div>
|
||||
<div text="xs neutral-400">
|
||||
Started at {{ lastRecording?.startedAt.toFixed(0) }} ms, duration
|
||||
{{ (lastRecording!.stoppedAt - lastRecording!.startedAt).toFixed(0) }} ms
|
||||
</div>
|
||||
<div text="xs neutral-400">
|
||||
Samples:
|
||||
FPS {{ lastRecording?.samples.fps.length }},
|
||||
Frames {{ lastRecording?.samples.frameDuration.length }},
|
||||
Long tasks {{ lastRecording?.samples.longtask.length }},
|
||||
Memory {{ lastRecording?.samples.memory.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div text="xs neutral-500">
|
||||
Overlay is visible when any metric is enabled. Recording caps at 60s.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
</route>
|
||||
@@ -14,6 +14,18 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:sledgehammer-bold-duotone',
|
||||
to: '/devtools/audio-record',
|
||||
},
|
||||
{
|
||||
title: t('settings.pages.system.sections.section.developer.sections.section.performance-visualizer.title'),
|
||||
description: t('settings.pages.system.sections.section.developer.sections.section.performance-visualizer.description'),
|
||||
icon: 'i-solar:sledgehammer-bold-duotone',
|
||||
to: '/devtools/performance-visualizer',
|
||||
},
|
||||
{
|
||||
title: t('settings.pages.system.sections.section.developer.sections.section.markdown-stress.title'),
|
||||
description: t('settings.pages.system.sections.section.developer.sections.section.markdown-stress.description'),
|
||||
icon: 'i-solar:sledgehammer-bold-duotone',
|
||||
to: '/devtools/markdown-stress',
|
||||
},
|
||||
{
|
||||
title: 'Background Theme color blending',
|
||||
description: 'Test blending & theme',
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { TraceEvent } from '@proj-airi/stage-shared'
|
||||
|
||||
import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-shared'
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
|
||||
import { createLagSampler } from '../composables/perf/register-lag-sampler'
|
||||
|
||||
export type LagMetric = 'fps' | 'frameDuration' | 'longtask' | 'memory'
|
||||
|
||||
interface Sample {
|
||||
ts: number
|
||||
value: number
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
interface RecordingSnapshot {
|
||||
startedAt: number
|
||||
stoppedAt: number
|
||||
samples: Record<LagMetric, Sample[]>
|
||||
}
|
||||
|
||||
interface HistogramBin {
|
||||
start: number
|
||||
end: number
|
||||
count: number
|
||||
}
|
||||
|
||||
function createEmptySamples(): Record<LagMetric, Sample[]> {
|
||||
return {
|
||||
fps: [],
|
||||
frameDuration: [],
|
||||
longtask: [],
|
||||
memory: [],
|
||||
}
|
||||
}
|
||||
|
||||
function pruneSamples(buffer: Sample[], cutoffTs: number) {
|
||||
while (buffer.length && buffer[0].ts < cutoffTs)
|
||||
buffer.shift()
|
||||
}
|
||||
|
||||
function calcStats(values: number[]) {
|
||||
if (!values.length)
|
||||
return { avg: 0, p95: 0, latest: 0 }
|
||||
|
||||
const total = values.reduce((acc, n) => acc + n, 0)
|
||||
const avg = total / values.length
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const idx = Math.max(0, Math.floor(0.95 * (sorted.length - 1)))
|
||||
const p95 = sorted[idx]
|
||||
const latest = values[values.length - 1]
|
||||
|
||||
return { avg, p95, latest }
|
||||
}
|
||||
|
||||
function buildHistogram(values: number[], bins = 20): HistogramBin[] {
|
||||
if (!values.length)
|
||||
return []
|
||||
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
if (min === max) {
|
||||
return [{
|
||||
start: min,
|
||||
end: max || min + 1,
|
||||
count: values.length,
|
||||
}]
|
||||
}
|
||||
|
||||
const width = (max - min) / bins
|
||||
const buckets = Array.from({ length: bins }, (_, idx) => ({
|
||||
start: min + (idx * width),
|
||||
end: min + ((idx + 1) * width),
|
||||
count: 0,
|
||||
}))
|
||||
|
||||
for (const value of values) {
|
||||
let binIndex = Math.floor((value - min) / width)
|
||||
if (binIndex >= bins)
|
||||
binIndex = bins - 1
|
||||
|
||||
buckets[binIndex].count += 1
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
||||
const enabled = reactive({
|
||||
fps: false,
|
||||
frameDuration: false,
|
||||
longtask: false,
|
||||
memory: false,
|
||||
})
|
||||
|
||||
const windowMs = 10000
|
||||
const buffers = reactive(createEmptySamples())
|
||||
|
||||
const recording = ref(false)
|
||||
const recordingStartedAt = ref<number | null>(null)
|
||||
const recordingSamples = reactive(createEmptySamples())
|
||||
const lastRecording = ref<RecordingSnapshot>()
|
||||
let recordingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const sampler = createLagSampler(defaultPerfTracer)
|
||||
let unsubscribeTracer: (() => void) | undefined
|
||||
let releaseTracer: (() => void) | undefined
|
||||
|
||||
function resetRecordingSamples() {
|
||||
for (const metric of Object.keys(recordingSamples) as LagMetric[])
|
||||
recordingSamples[metric] = []
|
||||
}
|
||||
|
||||
function applySample(metric: LagMetric, value: number, meta?: Record<string, any>) {
|
||||
const ts = performance.now()
|
||||
const cutoff = ts - windowMs
|
||||
|
||||
const buffer = buffers[metric]
|
||||
buffer.push({ ts, value, meta })
|
||||
pruneSamples(buffer, cutoff)
|
||||
|
||||
if (recording.value) {
|
||||
const sampleBuffer = recordingSamples[metric]
|
||||
sampleBuffer.push({ ts, value, meta })
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
if (recording.value)
|
||||
return
|
||||
|
||||
recording.value = true
|
||||
recordingStartedAt.value = performance.now()
|
||||
resetRecordingSamples()
|
||||
|
||||
recordingTimeout = setTimeout(() => stopRecording(), 60000)
|
||||
}
|
||||
|
||||
function stopRecording(): RecordingSnapshot | undefined {
|
||||
if (!recording.value)
|
||||
return
|
||||
|
||||
if (recordingTimeout) {
|
||||
clearTimeout(recordingTimeout)
|
||||
recordingTimeout = undefined
|
||||
}
|
||||
|
||||
const stoppedAt = performance.now()
|
||||
const snapshot: RecordingSnapshot = {
|
||||
startedAt: recordingStartedAt.value || stoppedAt,
|
||||
stoppedAt,
|
||||
samples: {
|
||||
fps: [...recordingSamples.fps],
|
||||
frameDuration: [...recordingSamples.frameDuration],
|
||||
longtask: [...recordingSamples.longtask],
|
||||
memory: [...recordingSamples.memory],
|
||||
},
|
||||
}
|
||||
lastRecording.value = snapshot
|
||||
|
||||
resetRecordingSamples()
|
||||
recordingStartedAt.value = null
|
||||
recording.value = false
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
sampler.stop()
|
||||
if (unsubscribeTracer) {
|
||||
unsubscribeTracer()
|
||||
unsubscribeTracer = undefined
|
||||
}
|
||||
releaseTracer?.()
|
||||
releaseTracer = undefined
|
||||
}
|
||||
|
||||
function ensureSampler() {
|
||||
const anyEnabled = enabled.fps || enabled.frameDuration || enabled.longtask || enabled.memory
|
||||
if (!anyEnabled) {
|
||||
stopAll()
|
||||
return
|
||||
}
|
||||
|
||||
// Start tracer listener if needed
|
||||
if (!unsubscribeTracer) {
|
||||
unsubscribeTracer = defaultPerfTracer.subscribe((event: TraceEvent) => {
|
||||
if (event.tracerId !== 'lag')
|
||||
return
|
||||
|
||||
const metric = event.name as LagMetric
|
||||
if (!['fps', 'frameDuration', 'longtask', 'memory'].includes(metric))
|
||||
return
|
||||
|
||||
// Only accept samples for enabled metrics
|
||||
const isMetricEnabled = enabled[metric]
|
||||
|
||||
if (!isMetricEnabled)
|
||||
return
|
||||
|
||||
const value = typeof event.duration === 'number' ? event.duration : 0
|
||||
applySample(metric, value, event.meta)
|
||||
})
|
||||
}
|
||||
|
||||
if (!releaseTracer)
|
||||
releaseTracer = defaultPerfTracer.acquire('lag-overlay')
|
||||
sampler.start({
|
||||
fps: enabled.fps,
|
||||
frameDuration: enabled.frameDuration,
|
||||
longtask: enabled.longtask,
|
||||
memory: enabled.memory,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAll(on: boolean) {
|
||||
enabled.fps = on
|
||||
enabled.frameDuration = on
|
||||
enabled.longtask = on
|
||||
enabled.memory = on
|
||||
ensureSampler()
|
||||
}
|
||||
|
||||
function exportCsv(snapshot?: RecordingSnapshot) {
|
||||
const target = snapshot ?? lastRecording.value
|
||||
if (!target)
|
||||
return
|
||||
|
||||
const rows: Array<Array<string | number>> = [['metric', 'ts', 'value', 'meta']]
|
||||
for (const metric of Object.keys(target.samples) as LagMetric[]) {
|
||||
for (const sample of target.samples[metric]) {
|
||||
rows.push([
|
||||
metric,
|
||||
sample.ts.toFixed(3),
|
||||
sample.value,
|
||||
JSON.stringify(sample.meta ?? {}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
exportCsvFile(rows, 'lag-recording')
|
||||
}
|
||||
|
||||
// React to enablement changes
|
||||
watch(
|
||||
() => ({ ...enabled }),
|
||||
() => {
|
||||
ensureSampler()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Cleanup on tab close
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
stopRecording()
|
||||
stopAll()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
buffers,
|
||||
recording,
|
||||
lastRecording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
exportCsv,
|
||||
toggleAll,
|
||||
calcStats,
|
||||
buildHistogram,
|
||||
}
|
||||
})
|
||||
@@ -738,6 +738,12 @@ pages:
|
||||
title: Developer
|
||||
sections:
|
||||
section:
|
||||
performance-visualizer:
|
||||
title: Performance Visualizer
|
||||
description: Toggle FPS/long task/memory overlay
|
||||
markdown-stress:
|
||||
title: Markdown Stress
|
||||
description: Stress markdown parsing and rendering with heavy chat payloads
|
||||
use-magic-keys:
|
||||
title: useMagicKeys
|
||||
description: Test shortcuts
|
||||
|
||||
@@ -32,5 +32,13 @@ devtools:
|
||||
pages:
|
||||
context-flow:
|
||||
title: Context Flow
|
||||
lag-visualizer:
|
||||
title: Lag Visualizer
|
||||
performance-visualizer:
|
||||
title: Performance Visualizer
|
||||
description: Toggle FPS/long task/memory overlay
|
||||
markdown-stress:
|
||||
title: Markdown Stress
|
||||
description: Stress markdown parsing and rendering with heavy chat payloads
|
||||
widgets-calling:
|
||||
title: Widget Calling
|
||||
|
||||
@@ -114,6 +114,14 @@ const routeHeaderMetadataMap = computed(() => {
|
||||
subtitle: t('tamagotchi.settings.devtools.title'),
|
||||
title: t('tamagotchi.settings.devtools.pages.context-flow.title'),
|
||||
},
|
||||
'/devtools/performance-visualizer': {
|
||||
subtitle: t('settings.title'),
|
||||
title: t('settings.pages.system.sections.section.developer.sections.section.performance-visualizer.title'),
|
||||
},
|
||||
'/devtools/markdown-stress': {
|
||||
subtitle: t('settings.title'),
|
||||
title: t('settings.pages.system.sections.section.developer.sections.section.markdown-stress.title'),
|
||||
},
|
||||
}
|
||||
|
||||
for (const metadata of allProvidersMetadata.value) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonBar, Callout, Section } from '@proj-airi/stage-ui/components'
|
||||
import { useMarkdownStressStore } from '@proj-airi/stage-ui/stores/markdown-stress'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const stressStore = useMarkdownStressStore()
|
||||
const { t } = useI18n()
|
||||
const { capturing, events, isMock, lastRun, payloadPreview, scheduleDelayMs, runState } = storeToRefs(stressStore)
|
||||
|
||||
const previewText = computed(() => payloadPreview.value ?? '')
|
||||
const lastRunSummary = computed(() => {
|
||||
if (!lastRun.value)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
events: lastRun.value.events.length,
|
||||
durationMs: (lastRun.value.stoppedAt - lastRun.value.startedAt).toFixed(0),
|
||||
}
|
||||
})
|
||||
const runSummary = computed(() => {
|
||||
return `Run: ${runState.value}, capturing: ${capturing.value ? 'yes' : 'no'}, events: ${events.value.length}`
|
||||
})
|
||||
|
||||
function toggleCapture() {
|
||||
if (capturing.value)
|
||||
stressStore.stopCapture()
|
||||
else
|
||||
stressStore.startCapture()
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
stressStore.toggleMockMode()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4 p-4 lg:grid-cols-[1.5fr_1fr]">
|
||||
<Section
|
||||
:title="t('settings.pages.system.sections.section.developer.sections.section.markdown-stress.title')"
|
||||
:description="t('settings.pages.system.sections.section.developer.sections.section.markdown-stress.description')"
|
||||
icon="i-solar:code-circle-bold-duotone"
|
||||
inner-class="gap-4"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonBar
|
||||
class="w-full sm:w-auto"
|
||||
icon="i-solar:magic-stick-bold-duotone"
|
||||
text="Preview"
|
||||
@click="stressStore.generatePreview()"
|
||||
>
|
||||
Generate payload preview
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
class="w-full sm:w-auto"
|
||||
icon="i-solar:play-circle-bold-duotone"
|
||||
:text="runState === 'running' ? 'Abort run' : runState === 'scheduled' ? 'Unschedule' : 'Replay'"
|
||||
:disabled="!isMock && !stressStore.canRunOnline"
|
||||
@click="stressStore.scheduleRun()"
|
||||
>
|
||||
{{ runState === 'running' ? 'Abort now' : runState === 'scheduled' ? 'Cancel replay' : 'Replay to provider' }}
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
class="w-full sm:w-auto"
|
||||
:icon="capturing ? 'i-solar:stop-circle-bold-duotone' : 'i-solar:recive-bold-duotone'"
|
||||
text="Capture"
|
||||
@click="toggleCapture"
|
||||
>
|
||||
{{ capturing ? 'Stop capture' : 'Start capture' }}
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
class="w-full sm:w-auto"
|
||||
icon="i-solar:export-bold-duotone"
|
||||
text="Export"
|
||||
:disabled="!lastRun?.events.length"
|
||||
@click="stressStore.exportCsv()"
|
||||
>
|
||||
Export last run
|
||||
</ButtonBar>
|
||||
<ButtonBar
|
||||
class="w-full sm:w-auto"
|
||||
:icon="isMock ? 'i-solar:simplerockets-bold-duotone' : 'i-solar:cloud-bold-duotone'"
|
||||
:text="isMock ? 'Mode: Mock' : 'Mode: Live'"
|
||||
@click="toggleMode"
|
||||
>
|
||||
{{ isMock ? 'Switch to live provider' : 'Switch to mock stream' }}
|
||||
</ButtonBar>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-[auto_1fr] md:items-center">
|
||||
<label class="text-xs text-neutral-400">Schedule delay (ms)</label>
|
||||
<input
|
||||
v-model.number="scheduleDelayMs"
|
||||
type="number"
|
||||
min="0"
|
||||
class="max-w-[180px] w-full border border-neutral-700 rounded bg-neutral-900 px-2 py-1 text-sm text-neutral-100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<Callout label="Run state" theme="violet">
|
||||
<div class="text-xs text-neutral-200">
|
||||
{{ runSummary }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500">
|
||||
Capture to record recent events; export last run for offline review.
|
||||
</div>
|
||||
</Callout>
|
||||
|
||||
<Callout v-if="lastRunSummary" label="Last run" theme="orange">
|
||||
<div class="text-xs text-neutral-200">
|
||||
{{ lastRunSummary.events }} events, duration {{ lastRunSummary.durationMs }} ms
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500">
|
||||
Export the last run to CSV to share with the team.
|
||||
</div>
|
||||
</Callout>
|
||||
</Section>
|
||||
|
||||
<div class="border border-neutral-800/70 rounded-xl bg-neutral-900/60 p-4 shadow-sm lg:col-span-1 space-y-3">
|
||||
<div class="text-sm text-neutral-200">
|
||||
Live traces
|
||||
</div>
|
||||
<div class="text-xs text-neutral-400">
|
||||
Capturing: {{ capturing ? 'yes' : 'no' }}, events: {{ events.length }}
|
||||
</div>
|
||||
<ul class="max-h-64 overflow-auto text-xs text-neutral-300 space-y-1">
|
||||
<li v-for="(event, idx) in events.slice(-20).reverse()" :key="idx">
|
||||
<span class="text-neutral-100 font-mono">{{ event.name }}</span>
|
||||
— {{ (event.duration ?? 0).toFixed(2) }} ms
|
||||
<span v-if="event.meta" class="text-neutral-500"> {{ JSON.stringify(event.meta) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Section
|
||||
title="Payload preview"
|
||||
icon="i-solar:code-file-bold-duotone"
|
||||
inner-class="gap-3"
|
||||
class="lg:col-span-2"
|
||||
>
|
||||
<div class="text-xs text-neutral-300">
|
||||
Latest payload
|
||||
</div>
|
||||
|
||||
<div v-if="previewText" class="border border-neutral-700 rounded-lg border-dashed bg-neutral-900/60 p-3 space-y-2">
|
||||
<pre class="max-h-60 overflow-auto whitespace-pre-wrap text-xs text-neutral-200">{{ previewText }}</pre>
|
||||
</div>
|
||||
<div v-else class="text-xs text-neutral-500">
|
||||
Generate a payload to see the preview.
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
</route>
|
||||
@@ -0,0 +1,28 @@
|
||||
function quoteField(field: unknown): string {
|
||||
return `"${String(field).replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function toCsv(rows: Array<Array<unknown>>): string {
|
||||
return rows
|
||||
.map(row => row.map(quoteField).join(','))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function exportCsv(rows: Array<Array<unknown>>, basename: string) {
|
||||
if (!rows.length)
|
||||
return
|
||||
|
||||
if (typeof Blob === 'undefined' || typeof document === 'undefined' || typeof URL === 'undefined') {
|
||||
console.warn('[CSV] Export is only supported in browser environments')
|
||||
return
|
||||
}
|
||||
|
||||
const csv = toCsv(rows)
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${basename}-${Date.now()}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './environment'
|
||||
export * from './export-csv'
|
||||
export * from './perf/tracer'
|
||||
export * from './url'
|
||||
export * from './window'
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
export interface TraceEvent {
|
||||
tracerId: string
|
||||
name: string
|
||||
ts: number
|
||||
duration?: number
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
export type TraceSubscriber = (event: TraceEvent) => void
|
||||
|
||||
export interface PerfTracer {
|
||||
forceDisable: () => void
|
||||
isEnabled: () => boolean
|
||||
acquire: (token?: string) => () => void
|
||||
release: (token?: string) => void
|
||||
subscribe: (subscriber: TraceSubscriber) => () => void
|
||||
subscribeSafe: (
|
||||
subscriber: TraceSubscriber,
|
||||
options?: { label?: string, onError?: (error: unknown, event: TraceEvent) => void },
|
||||
) => () => void
|
||||
emit: (event: TraceEvent) => void
|
||||
mark: (tracerId: string, name: string, meta?: Record<string, any>) => void
|
||||
withMeasure: <T>(
|
||||
tracerId: string,
|
||||
name: string,
|
||||
fn: () => Promise<T> | T,
|
||||
meta?: Record<string, any>,
|
||||
) => Promise<T>
|
||||
}
|
||||
|
||||
export function createPerfTracer(): PerfTracer {
|
||||
let enabled = false
|
||||
const leases = new Map<string, number>()
|
||||
const subscribers = new Set<TraceSubscriber>()
|
||||
|
||||
function push(event: TraceEvent, force = false) {
|
||||
if (!enabled && !force)
|
||||
return
|
||||
|
||||
for (const subscriber of subscribers)
|
||||
subscriber(event)
|
||||
}
|
||||
|
||||
function recomputeEnabled() {
|
||||
let total = 0
|
||||
for (const count of leases.values())
|
||||
total += count
|
||||
enabled = total > 0
|
||||
}
|
||||
|
||||
function acquire(token = '__default__') {
|
||||
const next = (leases.get(token) ?? 0) + 1
|
||||
leases.set(token, next)
|
||||
recomputeEnabled()
|
||||
return () => release(token)
|
||||
}
|
||||
|
||||
function release(token = '__default__') {
|
||||
const current = leases.get(token) ?? 0
|
||||
if (current <= 1)
|
||||
leases.delete(token)
|
||||
else
|
||||
leases.set(token, current - 1)
|
||||
recomputeEnabled()
|
||||
}
|
||||
|
||||
function forceDisable() {
|
||||
leases.clear()
|
||||
enabled = false
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return enabled
|
||||
}
|
||||
|
||||
function subscribe(subscriber: TraceSubscriber) {
|
||||
subscribers.add(subscriber)
|
||||
return () => subscribers.delete(subscriber)
|
||||
}
|
||||
|
||||
function subscribeSafe(
|
||||
subscriber: TraceSubscriber,
|
||||
options?: { label?: string, onError?: (error: unknown, event: TraceEvent) => void },
|
||||
) {
|
||||
const wrapped: TraceSubscriber = (event) => {
|
||||
try {
|
||||
subscriber(event)
|
||||
}
|
||||
catch (error) {
|
||||
if (options?.onError)
|
||||
options.onError(error, event)
|
||||
else
|
||||
console.error(`[PerfTracer] subscriber${options?.label ? ` (${options.label})` : ''} threw`, error, event)
|
||||
}
|
||||
}
|
||||
return subscribe(wrapped)
|
||||
}
|
||||
|
||||
function emit(event: TraceEvent) {
|
||||
push(event, false)
|
||||
}
|
||||
|
||||
function mark(tracerId: string, name: string, meta?: Record<string, any>) {
|
||||
push({
|
||||
tracerId,
|
||||
name,
|
||||
ts: performance.now(),
|
||||
meta,
|
||||
}, false)
|
||||
}
|
||||
|
||||
async function withMeasure<T>(
|
||||
tracerId: string,
|
||||
name: string,
|
||||
fn: () => Promise<T> | T,
|
||||
meta?: Record<string, any>,
|
||||
) {
|
||||
const shouldEmit = enabled
|
||||
if (!shouldEmit)
|
||||
return fn()
|
||||
|
||||
const start = performance.now()
|
||||
try {
|
||||
return await fn()
|
||||
}
|
||||
finally {
|
||||
const disabledAtEnd = !enabled
|
||||
push({
|
||||
tracerId,
|
||||
name,
|
||||
ts: start,
|
||||
duration: performance.now() - start,
|
||||
meta: disabledAtEnd
|
||||
? { ...meta, tracerDisabledDuringMeasure: true }
|
||||
: meta,
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
forceDisable,
|
||||
isEnabled,
|
||||
acquire,
|
||||
release,
|
||||
subscribe,
|
||||
subscribeSafe,
|
||||
emit,
|
||||
mark,
|
||||
withMeasure,
|
||||
}
|
||||
}
|
||||
|
||||
// Default singleton used across surfaces. Devtools should call enable/disable explicitly.
|
||||
export const defaultPerfTracer = createPerfTracer()
|
||||
@@ -9,6 +9,7 @@ import remarkMath from 'remark-math'
|
||||
import RemarkParse from 'remark-parse'
|
||||
import RemarkRehype from 'remark-rehype'
|
||||
|
||||
import { defaultPerfTracer } from '@proj-airi/stage-shared'
|
||||
import { unified } from 'unified'
|
||||
|
||||
// Define a specific, compatible type for our processor to ensure type safety.
|
||||
@@ -28,6 +29,26 @@ function extractLangs(markdown: string): BundledLanguage[] {
|
||||
return [...langs]
|
||||
}
|
||||
|
||||
function measuredKatex(options?: Parameters<typeof rehypeKatex>[0]) {
|
||||
const transform = rehypeKatex(options)
|
||||
return (tree: any, file: any) => {
|
||||
const start = performance.now()
|
||||
const length = typeof file?.value === 'string' ? file.value.length : undefined
|
||||
try {
|
||||
return transform(tree, file)
|
||||
}
|
||||
finally {
|
||||
defaultPerfTracer.emit({
|
||||
tracerId: 'markdown',
|
||||
name: 'process.katex',
|
||||
ts: start,
|
||||
duration: performance.now() - start,
|
||||
meta: { length },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createProcessor(langs: BundledLanguage[]): Promise<MarkdownProcessor> {
|
||||
const options: RehypeShikiOptions = {
|
||||
themes: {
|
||||
@@ -42,7 +63,7 @@ async function createProcessor(langs: BundledLanguage[]): Promise<MarkdownProces
|
||||
.use(RemarkParse)
|
||||
.use(remarkMath)
|
||||
.use(RemarkRehype)
|
||||
.use(rehypeKatex, { output: 'mathml' })
|
||||
.use(measuredKatex, { output: 'mathml' })
|
||||
.use(rehypeShiki, options)
|
||||
.use(RehypeStringify)
|
||||
}
|
||||
@@ -64,42 +85,63 @@ export function useMarkdown() {
|
||||
.use(RemarkParse)
|
||||
.use(remarkMath)
|
||||
.use(RemarkRehype)
|
||||
.use(rehypeKatex, { output: 'mathml' })
|
||||
.use(measuredKatex, { output: 'mathml' })
|
||||
.use(RehypeStringify)
|
||||
|
||||
return {
|
||||
process: async (markdown: string): Promise<string> => {
|
||||
try {
|
||||
// A quick check for code fences. If none, use the fast fallback.
|
||||
if (!/`{3,}/.test(markdown))
|
||||
return fallbackProcessor.processSync(markdown).toString()
|
||||
const hasCodeFence = /`{3,}/.test(markdown)
|
||||
const meta = { length: markdown.length, hasCodeFence }
|
||||
|
||||
const langs = extractLangs(markdown)
|
||||
return defaultPerfTracer.withMeasure('markdown', 'process', async () => {
|
||||
try {
|
||||
// A quick check for code fences. If none, use the fast fallback.
|
||||
if (!hasCodeFence) {
|
||||
return defaultPerfTracer.withMeasure('markdown', 'process.pipeline.basic', () => {
|
||||
return fallbackProcessor.processSync(markdown).toString()
|
||||
}, meta)
|
||||
}
|
||||
|
||||
// Always ensure 'python' is loaded as it's our default.
|
||||
const langSet = new Set(langs)
|
||||
langSet.add('python')
|
||||
const languagesToLoad = Array.from(langSet)
|
||||
const langs = extractLangs(markdown)
|
||||
|
||||
const processor = await getProcessor(languagesToLoad)
|
||||
const result = await processor.process(markdown)
|
||||
return result.toString()
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(
|
||||
'Failed to process markdown with syntax highlighting, falling back to basic processing:',
|
||||
error,
|
||||
)
|
||||
// Fallback to basic processor without highlighting
|
||||
return fallbackProcessor.processSync(markdown).toString()
|
||||
}
|
||||
// Always ensure 'python' is loaded as it's our default.
|
||||
const langSet = new Set(langs)
|
||||
langSet.add('python')
|
||||
const languagesToLoad = Array.from(langSet)
|
||||
|
||||
const processor = await getProcessor(languagesToLoad)
|
||||
const result = await defaultPerfTracer.withMeasure('markdown', 'process.pipeline.rich', () => processor.process(markdown), meta)
|
||||
return result.toString()
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(
|
||||
'Failed to process markdown with syntax highlighting, falling back to basic processing:',
|
||||
error,
|
||||
)
|
||||
// Fallback to basic processor without highlighting
|
||||
return defaultPerfTracer.withMeasure('markdown', 'process.pipeline.fallback', () => {
|
||||
return fallbackProcessor.processSync(markdown).toString()
|
||||
}, { ...meta, fallback: true })
|
||||
}
|
||||
}, meta)
|
||||
},
|
||||
|
||||
// Synchronous version for backward compatibility
|
||||
processSync: (markdown: string): string => {
|
||||
return fallbackProcessor
|
||||
const start = performance.now()
|
||||
const output = fallbackProcessor
|
||||
.processSync(markdown)
|
||||
.toString()
|
||||
|
||||
defaultPerfTracer.emit({
|
||||
tracerId: 'markdown',
|
||||
name: 'process.pipeline.sync',
|
||||
ts: start,
|
||||
duration: performance.now() - start,
|
||||
meta: { length: markdown.length },
|
||||
})
|
||||
|
||||
return output
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const CONTEXT_CHANNEL_NAME = 'airi-context-update'
|
||||
export const CHAT_STREAM_CHANNEL_NAME = 'airi-chat-stream'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const llmStore = useLLM()
|
||||
const { stream, discoverToolsCompatibility } = useLLM()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider } = storeToRefs(consciousnessStore)
|
||||
@@ -239,6 +240,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
return nextGeneration
|
||||
}
|
||||
|
||||
function getSessionGenerationValue(sessionId = activeSessionId.value) {
|
||||
return getSessionGeneration(sessionId)
|
||||
}
|
||||
|
||||
function generateInitialMessage() {
|
||||
// TODO: compose, replace {{ user }} tag, etc
|
||||
const content = codeBlockSystemPrompt + mathSyntaxSystemPrompt + systemPrompt.value
|
||||
@@ -521,7 +526,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (shouldAbort())
|
||||
return
|
||||
|
||||
await stream(options.model, options.chatProvider, newMessages as Message[], {
|
||||
await llmStore.stream(options.model, options.chatProvider, newMessages as Message[], {
|
||||
headers,
|
||||
tools: options.tools,
|
||||
onStreamEvent: async (event: StreamEvent) => {
|
||||
@@ -593,6 +598,36 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Remote stream helpers (for broadcast/devtools) -----
|
||||
function beginRemoteStream() {
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() }
|
||||
}
|
||||
|
||||
function appendRemoteLiteral(literal: string) {
|
||||
streamingMessage.value.content += literal
|
||||
|
||||
const lastSlice = streamingMessage.value.slices.at(-1)
|
||||
if (lastSlice?.type === 'text') {
|
||||
lastSlice.text += literal
|
||||
return
|
||||
}
|
||||
|
||||
streamingMessage.value.slices.push({
|
||||
type: 'text',
|
||||
text: literal,
|
||||
})
|
||||
}
|
||||
|
||||
function finalizeRemoteStream(fullText?: string) {
|
||||
const sessionId = activeSessionId.value
|
||||
const sessionMessagesForSend = getSessionMessagesById(sessionId)
|
||||
if (streamingMessage.value.slices.length > 0)
|
||||
sessionMessagesForSend.push(toRaw(streamingMessage.value))
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
|
||||
if (fullText)
|
||||
streamingMessage.value.content = fullText
|
||||
}
|
||||
|
||||
async function send(
|
||||
sendingMessage: string,
|
||||
options: SendOptions,
|
||||
@@ -617,7 +652,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
messages,
|
||||
streamingMessage,
|
||||
|
||||
discoverToolsCompatibility,
|
||||
discoverToolsCompatibility: llmStore.discoverToolsCompatibility,
|
||||
|
||||
send,
|
||||
setActiveSession,
|
||||
@@ -641,6 +676,12 @@ export const useChatStore = defineStore('chat', () => {
|
||||
emitAssistantMessageHooks,
|
||||
emitChatTurnCompleteHooks,
|
||||
|
||||
getSessionGenerationValue,
|
||||
|
||||
beginRemoteStream,
|
||||
appendRemoteLiteral,
|
||||
finalizeRemoteStream,
|
||||
|
||||
onBeforeMessageComposed,
|
||||
onAfterMessageComposed,
|
||||
onBeforeSend,
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { TraceEvent } from '@proj-airi/stage-shared'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { StreamEvent } from './llm'
|
||||
|
||||
import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-shared'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useChatStore } from './chat'
|
||||
import { useLLM } from './llm'
|
||||
import { useConsciousnessStore } from './modules/consciousness'
|
||||
import { usePerfTracerBridgeStore } from './perf-tracer-bridge'
|
||||
import { useProvidersStore } from './providers'
|
||||
|
||||
interface DeterministicTimer {
|
||||
now: () => number
|
||||
schedule: (delayMs: number, fn: () => void | Promise<void>) => number
|
||||
cancel: (id: number) => void
|
||||
tick: (ms: number) => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
function createDeterministicTimer(startAt = 0): DeterministicTimer {
|
||||
interface Scheduled {
|
||||
id: number
|
||||
at: number
|
||||
fn: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let now = startAt
|
||||
let nextId = 1
|
||||
const queue: Scheduled[] = []
|
||||
|
||||
function schedule(delayMs: number, fn: Scheduled['fn']) {
|
||||
const id = nextId++
|
||||
const at = now + Math.max(0, delayMs)
|
||||
queue.push({ id, at, fn })
|
||||
queue.sort((a, b) => a.at === b.at ? a.id - b.id : a.at - b.at)
|
||||
return id
|
||||
}
|
||||
|
||||
function cancel(id: number) {
|
||||
const index = queue.findIndex(job => job.id === id)
|
||||
if (index !== -1)
|
||||
queue.splice(index, 1)
|
||||
}
|
||||
|
||||
async function tick(ms: number) {
|
||||
const target = now + Math.max(0, ms)
|
||||
while (queue[0]?.at !== undefined && queue[0].at <= target) {
|
||||
const job = queue.shift()!
|
||||
now = job.at
|
||||
await job.fn()
|
||||
}
|
||||
now = target
|
||||
}
|
||||
|
||||
function clear() {
|
||||
queue.length = 0
|
||||
now = 0
|
||||
}
|
||||
|
||||
return {
|
||||
now: () => now,
|
||||
schedule,
|
||||
cancel,
|
||||
tick,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
function chunkText(text: string, size: number) {
|
||||
if (size <= 0)
|
||||
return [text]
|
||||
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < text.length; i += size)
|
||||
chunks.push(text.slice(i, i + size))
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
function createMockStream(options: {
|
||||
scenario: DevtoolsChatScenario
|
||||
timer: DeterministicTimer
|
||||
onEvent: (event: StreamEvent) => void | Promise<void>
|
||||
}) {
|
||||
let cancelled = false
|
||||
const {
|
||||
scenario: {
|
||||
assistant: {
|
||||
text,
|
||||
firstTokenDelayMs = 0,
|
||||
rate,
|
||||
},
|
||||
},
|
||||
timer,
|
||||
onEvent,
|
||||
} = options
|
||||
|
||||
const chunks = chunkText(text, Math.max(1, rate?.maxChunkSize ?? 96))
|
||||
const intervalMs = 1000 / Math.max(1, rate?.tokensPerSecond ?? 40)
|
||||
|
||||
async function run() {
|
||||
const yieldMacro = () => new Promise(resolve => setTimeout(resolve, 0))
|
||||
let lastTs = timer.now()
|
||||
const base = lastTs + firstTokenDelayMs
|
||||
|
||||
for (const [idx, chunk] of chunks.entries()) {
|
||||
if (cancelled)
|
||||
return
|
||||
const target = base + idx * intervalMs
|
||||
await timer.tick(target - lastTs)
|
||||
lastTs = target
|
||||
await onEvent({ type: 'text-delta', text: chunk })
|
||||
await yieldMacro()
|
||||
}
|
||||
|
||||
if (cancelled)
|
||||
return
|
||||
|
||||
const finishAt = base + chunks.length * intervalMs
|
||||
await timer.tick(finishAt - lastTs)
|
||||
await onEvent({ type: 'finish' } as StreamEvent)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
interface RunSnapshot {
|
||||
startedAt: number
|
||||
stoppedAt: number
|
||||
events: TraceEvent[]
|
||||
}
|
||||
|
||||
interface DevtoolsChatScenario {
|
||||
userMessages: Array<{ atMs: number, text: string }>
|
||||
assistant: {
|
||||
text: string
|
||||
firstTokenDelayMs?: number
|
||||
rate?: {
|
||||
tokensPerSecond?: number
|
||||
jitterMs?: number
|
||||
maxChunkSize?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useMarkdownStressStore = defineStore('markdownStress', () => {
|
||||
const capturing = ref(false)
|
||||
const events = ref<TraceEvent[]>([])
|
||||
const lastRun = ref<RunSnapshot>()
|
||||
const payloadPreview = ref<string>('')
|
||||
const scheduleDelayMs = ref(10000)
|
||||
const runState = ref<'idle' | 'scheduled' | 'running'>('idle')
|
||||
const scenario = ref<DevtoolsChatScenario | null>(null)
|
||||
const isMock = ref(false)
|
||||
const canRunOnline = ref(true)
|
||||
const mockModelId = 'markdown-stress-mock'
|
||||
|
||||
const providersStore = useProvidersStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider, activeModel } = storeToRefs(consciousnessStore)
|
||||
const perfTracerBridge = usePerfTracerBridgeStore()
|
||||
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let startedAt = 0
|
||||
let releaseTracer: (() => void) | undefined
|
||||
let runTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let autoStopTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let inFlightTimers: Array<ReturnType<typeof setTimeout>> = []
|
||||
const runCleanups: Array<() => void> = []
|
||||
const mockTimer = createDeterministicTimer()
|
||||
let mockStreamCancel: (() => void) | undefined
|
||||
|
||||
function clearTimers() {
|
||||
if (runTimeout) {
|
||||
clearTimeout(runTimeout)
|
||||
runTimeout = undefined
|
||||
}
|
||||
if (autoStopTimeout) {
|
||||
clearTimeout(autoStopTimeout)
|
||||
autoStopTimeout = undefined
|
||||
}
|
||||
for (const timer of inFlightTimers)
|
||||
clearTimeout(timer)
|
||||
inFlightTimers = []
|
||||
mockStreamCancel?.()
|
||||
mockStreamCancel = undefined
|
||||
mockTimer.clear()
|
||||
}
|
||||
|
||||
function clearRunCleanups() {
|
||||
while (runCleanups.length) {
|
||||
const cleanup = runCleanups.pop()
|
||||
cleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
function startCapture() {
|
||||
if (capturing.value)
|
||||
return
|
||||
|
||||
capturing.value = true
|
||||
startedAt = performance.now()
|
||||
events.value = []
|
||||
|
||||
unsubscribe = defaultPerfTracer.subscribeSafe((event) => {
|
||||
if (event.tracerId !== 'markdown' && event.tracerId !== 'chat')
|
||||
return
|
||||
|
||||
events.value.push(event)
|
||||
}, { label: 'markdown-stress' })
|
||||
releaseTracer = defaultPerfTracer.acquire('markdown-stress')
|
||||
perfTracerBridge.requestEnable('markdown-stress')
|
||||
}
|
||||
|
||||
function stopCapture() {
|
||||
if (!capturing.value)
|
||||
return
|
||||
|
||||
clearTimers()
|
||||
clearRunCleanups()
|
||||
lastRun.value = {
|
||||
startedAt,
|
||||
stoppedAt: performance.now(),
|
||||
events: [...events.value],
|
||||
}
|
||||
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
releaseTracer?.()
|
||||
releaseTracer = undefined
|
||||
perfTracerBridge.requestDisable('markdown-stress')
|
||||
capturing.value = false
|
||||
runState.value = 'idle'
|
||||
}
|
||||
|
||||
function buildForFlood() {
|
||||
const line = 'for for for for for'
|
||||
// 800 lines * 5 words = 4000 tokens
|
||||
return Array.from({ length: 800 }, () => line).join('\n')
|
||||
}
|
||||
|
||||
function generateScenario(): DevtoolsChatScenario {
|
||||
const userPrompt = 'Give me a huge stress-test JavaScript block with 2000 occurrences of the keyword `for` wrapped in ```javascript```.'
|
||||
const followUp = 'I really need a JS block containing 2000 `for` keywords — please ensure the request is fully satisfied.'
|
||||
const assistantText = [
|
||||
'Here is a large JS `for` block (line breaks every 5 entries, about 4000 words total):',
|
||||
'```python',
|
||||
buildForFlood(),
|
||||
'```',
|
||||
'Done. This should heavily stress markdown parsing and rendering.',
|
||||
].join('\n\n')
|
||||
|
||||
return {
|
||||
userMessages: [
|
||||
{ atMs: 0, text: userPrompt },
|
||||
{ atMs: 1200, text: followUp },
|
||||
],
|
||||
assistant: {
|
||||
text: assistantText,
|
||||
firstTokenDelayMs: 150,
|
||||
rate: { tokensPerSecond: 120, jitterMs: 5, maxChunkSize: 96 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ensureScenario() {
|
||||
if (!scenario.value)
|
||||
scenario.value = generateScenario()
|
||||
return scenario.value
|
||||
}
|
||||
|
||||
function generatePreview() {
|
||||
const next = generateScenario()
|
||||
scenario.value = next
|
||||
payloadPreview.value = [
|
||||
`User (t=0ms): ${next.userMessages[0].text}`,
|
||||
`User (t=${next.userMessages[1].atMs}ms): ${next.userMessages[1].text}`,
|
||||
'--- Assistant stream ---',
|
||||
next.assistant.text,
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
async function runOnlineScenario() {
|
||||
const chatStore = useChatStore()
|
||||
const targetScenario = ensureScenario()
|
||||
|
||||
const provider = await providersStore.getProviderInstance(activeProvider.value) as ChatProvider | undefined
|
||||
if (!provider || !activeModel.value) {
|
||||
console.warn('[markdown-stress] No active provider/model for online mode')
|
||||
canRunOnline.value = false
|
||||
stopCapture()
|
||||
return
|
||||
}
|
||||
canRunOnline.value = true
|
||||
|
||||
const runStart = performance.now()
|
||||
for (const message of targetScenario.userMessages) {
|
||||
const delay = Math.max(0, runStart + message.atMs - performance.now())
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await chatStore.send(message.text, {
|
||||
model: activeModel.value!,
|
||||
chatProvider: provider,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[markdown-stress] Online send failed', error)
|
||||
}
|
||||
}, delay)
|
||||
inFlightTimers.push(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function runMockScenario() {
|
||||
const chatStore = useChatStore()
|
||||
const llm = useLLM()
|
||||
const targetScenario = ensureScenario()
|
||||
const modelToUse = mockModelId
|
||||
const mockProvider: ChatProvider = {
|
||||
chat(model: string) {
|
||||
return {
|
||||
baseURL: 'mock://markdown-stress/',
|
||||
apiKey: '',
|
||||
headers: {},
|
||||
model,
|
||||
} as any
|
||||
},
|
||||
} as ChatProvider
|
||||
|
||||
const originalStream = llm.stream
|
||||
llm.stream = async (_model, _provider, _messages, options) => {
|
||||
const runner = createMockStream({
|
||||
scenario: targetScenario,
|
||||
timer: mockTimer,
|
||||
onEvent: async (event) => {
|
||||
await options?.onStreamEvent?.(event)
|
||||
},
|
||||
})
|
||||
mockStreamCancel = runner.cancel
|
||||
try {
|
||||
await runner.run()
|
||||
}
|
||||
finally {
|
||||
mockStreamCancel = undefined
|
||||
}
|
||||
}
|
||||
runCleanups.push(() => {
|
||||
llm.stream = originalStream
|
||||
mockStreamCancel = undefined
|
||||
})
|
||||
|
||||
const runStart = performance.now()
|
||||
for (const message of targetScenario.userMessages) {
|
||||
const delay = Math.max(0, runStart + message.atMs - performance.now())
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await chatStore.send(message.text, {
|
||||
model: modelToUse,
|
||||
chatProvider: mockProvider,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[markdown-stress] Mock send failed', error)
|
||||
}
|
||||
}, delay)
|
||||
inFlightTimers.push(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleRun() {
|
||||
// if already scheduled, cancel
|
||||
if (runState.value === 'scheduled') {
|
||||
cancelScheduledRun()
|
||||
return
|
||||
}
|
||||
|
||||
// if already running, abort immediately
|
||||
if (runState.value === 'running') {
|
||||
stopCapture()
|
||||
return
|
||||
}
|
||||
|
||||
clearTimers()
|
||||
ensureScenario()
|
||||
runState.value = 'scheduled'
|
||||
|
||||
runTimeout = setTimeout(async () => {
|
||||
runState.value = 'running'
|
||||
runTimeout = undefined
|
||||
startCapture()
|
||||
if (isMock.value)
|
||||
await runMockScenario()
|
||||
else
|
||||
await runOnlineScenario()
|
||||
}, scheduleDelayMs.value)
|
||||
|
||||
autoStopTimeout = setTimeout(() => {
|
||||
stopCapture()
|
||||
}, scheduleDelayMs.value + 60000)
|
||||
}
|
||||
|
||||
function cancelScheduledRun() {
|
||||
clearTimers()
|
||||
clearRunCleanups()
|
||||
runState.value = 'idle'
|
||||
}
|
||||
|
||||
function setMockMode(enabled: boolean) {
|
||||
isMock.value = enabled
|
||||
if (enabled)
|
||||
canRunOnline.value = true
|
||||
}
|
||||
|
||||
function toggleMockMode() {
|
||||
setMockMode(!isMock.value)
|
||||
}
|
||||
|
||||
function exportCsv(snapshot?: RunSnapshot) {
|
||||
const target = snapshot ?? lastRun.value
|
||||
if (!target)
|
||||
return
|
||||
|
||||
const rows: Array<Array<string | number>> = [['tracerId', 'name', 'ts', 'duration', 'meta']]
|
||||
for (const event of target.events) {
|
||||
rows.push([
|
||||
event.tracerId,
|
||||
event.name,
|
||||
event.ts.toFixed(3),
|
||||
event.duration ?? '',
|
||||
JSON.stringify(event.meta ?? {}),
|
||||
])
|
||||
}
|
||||
|
||||
exportCsvFile(rows, 'markdown-stress')
|
||||
}
|
||||
|
||||
return {
|
||||
canRunOnline,
|
||||
capturing,
|
||||
events,
|
||||
lastRun,
|
||||
payloadPreview,
|
||||
scheduleDelayMs,
|
||||
runState,
|
||||
scenario,
|
||||
isMock,
|
||||
startCapture,
|
||||
stopCapture,
|
||||
scheduleRun,
|
||||
cancelScheduledRun,
|
||||
generatePreview,
|
||||
setMockMode,
|
||||
toggleMockMode,
|
||||
exportCsv,
|
||||
}
|
||||
})
|
||||
@@ -21,6 +21,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
const { post: broadcastStreamEvent, data: incomingStreamEvent } = useBroadcastChannel<ChatStreamEvent, ChatStreamEvent>({ name: CHAT_STREAM_CHANNEL_NAME })
|
||||
|
||||
const disposeHookFns = ref<Array<() => void>>([])
|
||||
let remoteStreamGuard: { sessionId: string, generation: number } | null = null
|
||||
|
||||
async function initialize() {
|
||||
await mutex.acquire()
|
||||
@@ -137,9 +138,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
isProcessingRemoteStream = true
|
||||
|
||||
try {
|
||||
if (event.sessionId && chatStore.activeSessionId !== event.sessionId)
|
||||
chatStore.setActiveSession(event.sessionId)
|
||||
|
||||
// Use the receiver's active session to avoid clobbering chat state when events come from other windows/devtools.
|
||||
switch (event.type) {
|
||||
case 'before-compose':
|
||||
await chatStore.emitBeforeMessageComposedHooks(event.message, event.context)
|
||||
@@ -149,21 +148,52 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
break
|
||||
case 'before-send':
|
||||
await chatStore.emitBeforeSendHooks(event.message, event.context)
|
||||
remoteStreamGuard = {
|
||||
sessionId: chatStore.activeSessionId,
|
||||
generation: chatStore.getSessionGenerationValue(),
|
||||
}
|
||||
chatStore.sending = true
|
||||
chatStore.beginRemoteStream()
|
||||
break
|
||||
case 'after-send':
|
||||
await chatStore.emitAfterSendHooks(event.message, event.context)
|
||||
break
|
||||
case 'token-literal':
|
||||
if (!remoteStreamGuard)
|
||||
return
|
||||
if (remoteStreamGuard.sessionId !== chatStore.activeSessionId)
|
||||
return
|
||||
if (chatStore.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
return
|
||||
chatStore.appendRemoteLiteral(event.literal)
|
||||
await chatStore.emitTokenLiteralHooks(event.literal, event.context)
|
||||
break
|
||||
case 'token-special':
|
||||
await chatStore.emitTokenSpecialHooks(event.special, event.context)
|
||||
break
|
||||
case 'stream-end':
|
||||
if (!remoteStreamGuard)
|
||||
break
|
||||
if (remoteStreamGuard.sessionId !== chatStore.activeSessionId)
|
||||
break
|
||||
if (chatStore.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
break
|
||||
await chatStore.emitStreamEndHooks(event.context)
|
||||
chatStore.finalizeRemoteStream()
|
||||
chatStore.sending = false
|
||||
remoteStreamGuard = null
|
||||
break
|
||||
case 'assistant-end':
|
||||
if (!remoteStreamGuard)
|
||||
break
|
||||
if (remoteStreamGuard.sessionId !== chatStore.activeSessionId)
|
||||
break
|
||||
if (chatStore.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
||||
break
|
||||
await chatStore.emitAssistantResponseEndHooks(event.message, event.context)
|
||||
chatStore.finalizeRemoteStream(event.message)
|
||||
chatStore.sending = false
|
||||
remoteStreamGuard = null
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { TraceEvent } from '@proj-airi/stage-shared'
|
||||
|
||||
import { defaultPerfTracer } from '@proj-airi/stage-shared'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
|
||||
type PerfTracerMode = 'forward' | 'receive'
|
||||
type PerfTracerState = 'idle' | PerfTracerMode
|
||||
|
||||
interface PerfTracerMessageEnable {
|
||||
type: 'enable'
|
||||
token?: string
|
||||
origin: string
|
||||
mode?: PerfTracerMode
|
||||
}
|
||||
|
||||
interface PerfTracerMessageDisable {
|
||||
type: 'disable'
|
||||
token?: string
|
||||
origin: string
|
||||
}
|
||||
|
||||
interface PerfTracerMessageEvent {
|
||||
type: 'event'
|
||||
event: TraceEvent
|
||||
origin: string
|
||||
}
|
||||
|
||||
type PerfTracerMessage = PerfTracerMessageEnable | PerfTracerMessageDisable | PerfTracerMessageEvent
|
||||
|
||||
const PERF_TRACER_CHANNEL = 'airi-perf-tracer'
|
||||
const RELAY_META_KEY = '__perfTracerRelayedFrom'
|
||||
const BRIDGE_TOKEN = 'perf-bridge'
|
||||
|
||||
export const usePerfTracerBridgeStore = defineStore('perfTracerBridge', () => {
|
||||
const instanceId = Math.random().toString(36).slice(2, 10)
|
||||
const { post, data } = useBroadcastChannel<PerfTracerMessage, PerfTracerMessage>({ name: PERF_TRACER_CHANNEL })
|
||||
|
||||
let release: (() => void) | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let state: PerfTracerState = 'idle'
|
||||
|
||||
function enableLocal(token = BRIDGE_TOKEN) {
|
||||
if (release)
|
||||
return
|
||||
release = defaultPerfTracer.acquire(token)
|
||||
}
|
||||
|
||||
function disableLocal() {
|
||||
release?.()
|
||||
release = undefined
|
||||
}
|
||||
|
||||
function startForwarding() {
|
||||
if (unsubscribe)
|
||||
return
|
||||
unsubscribe = defaultPerfTracer.subscribeSafe((event) => {
|
||||
if (event.tracerId !== 'markdown' && event.tracerId !== 'chat')
|
||||
return
|
||||
if (event.meta?.[RELAY_META_KEY])
|
||||
return
|
||||
post({
|
||||
type: 'event',
|
||||
event: {
|
||||
...event,
|
||||
meta: { ...event.meta, [RELAY_META_KEY]: instanceId },
|
||||
},
|
||||
origin: instanceId,
|
||||
})
|
||||
}, { label: 'perf-bridge' })
|
||||
}
|
||||
|
||||
function stopForwarding() {
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
}
|
||||
|
||||
function transition(next: PerfTracerState, token = BRIDGE_TOKEN) {
|
||||
if (state === next)
|
||||
return
|
||||
if (next === 'idle') {
|
||||
stopForwarding()
|
||||
disableLocal()
|
||||
}
|
||||
else if (next === 'receive') {
|
||||
enableLocal(token)
|
||||
stopForwarding()
|
||||
}
|
||||
else if (next === 'forward') {
|
||||
enableLocal(token)
|
||||
startForwarding()
|
||||
}
|
||||
state = next
|
||||
}
|
||||
|
||||
watch(data, (message) => {
|
||||
if (!message)
|
||||
return
|
||||
if (message.origin === instanceId)
|
||||
return
|
||||
|
||||
if (message.type === 'enable') {
|
||||
const mode: PerfTracerMode = message.mode ?? 'forward'
|
||||
const token = message.token ?? BRIDGE_TOKEN
|
||||
transition(mode, token)
|
||||
}
|
||||
else if (message.type === 'disable') {
|
||||
transition('idle')
|
||||
}
|
||||
else if (message.type === 'event') {
|
||||
if (state === 'idle')
|
||||
return
|
||||
// Replay remote events into the local tracer; requires tracer enabled to pass through.
|
||||
const relayedFrom = message.event.meta?.[RELAY_META_KEY] ?? message.origin
|
||||
defaultPerfTracer.emit({
|
||||
...message.event,
|
||||
meta: { ...message.event.meta, [RELAY_META_KEY]: relayedFrom },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function requestEnable(token?: string, mode: PerfTracerMode = 'forward', localState: PerfTracerState = mode) {
|
||||
const tokenToUse = token ?? BRIDGE_TOKEN
|
||||
transition(localState, tokenToUse)
|
||||
post({ type: 'enable', token: tokenToUse, origin: instanceId, mode })
|
||||
}
|
||||
|
||||
function requestDisable(token?: string) {
|
||||
transition('idle')
|
||||
post({ type: 'disable', token: token ?? BRIDGE_TOKEN, origin: instanceId })
|
||||
}
|
||||
|
||||
return {
|
||||
requestEnable,
|
||||
requestDisable,
|
||||
enableLocal,
|
||||
disableLocal,
|
||||
startForwarding,
|
||||
stopForwarding,
|
||||
}
|
||||
})
|
||||
Generated
+3
@@ -1239,6 +1239,9 @@ importers:
|
||||
'@proj-airi/stage-layouts':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-layouts
|
||||
'@proj-airi/stage-shared':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-shared
|
||||
'@proj-airi/stage-ui':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui
|
||||
|
||||
Reference in New Issue
Block a user