feat(stage-tamagotchi): show connection status (#1311)

This commit is contained in:
LemonNeko
2026-03-12 23:14:22 +08:00
committed by GitHub
parent 0d1dc34bd8
commit a80f038941
15 changed files with 320 additions and 24 deletions
+3 -2
View File
@@ -5,6 +5,7 @@ import type { I18n } from '../libs/i18n'
import type { ServerChannel } from '../services/airi/channel-server'
import type { setupBeatSync } from '../windows/beat-sync'
import type { setupCaptionWindowManager } from '../windows/caption'
import type { SettingsWindowManager } from '../windows/settings'
import type { WidgetsWindowManager } from '../windows/widgets'
import { env } from 'node:process'
@@ -79,7 +80,7 @@ function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number
export function setupTray(params: {
mainWindow: BrowserWindow
settingsWindow: () => Promise<BrowserWindow>
settingsWindow: SettingsWindowManager
captionWindow: ReturnType<typeof setupCaptionWindowManager>
widgetsWindow: WidgetsWindowManager
beatSyncBgWindow: Awaited<ReturnType<typeof setupBeatSync>>
@@ -172,7 +173,7 @@ export function setupTray(params: {
],
},
{ type: 'separator' },
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings'), click: () => params.settingsWindow().then(window => toggleWindowShow(window)) },
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings'), click: () => void params.settingsWindow.openWindow('/settings') },
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about'), click: () => params.aboutWindow().then(window => toggleWindowShow(window)) },
{ type: 'separator' },
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay'), click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }) },
@@ -4,6 +4,7 @@ import type { InferOutput } from 'valibot'
import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
import type { NoticeWindowManager } from '../notice'
import type { SettingsWindowManager } from '../settings'
import { dirname, join, resolve } from 'node:path'
import { env } from 'node:process'
@@ -41,7 +42,7 @@ const appConfigSchema = object({
type AppConfig = InferOutput<typeof appConfigSchema>
export async function setupDashboardWindow(params: {
settingsWindow: () => Promise<BrowserWindow>
settingsWindow: SettingsWindowManager
chatWindow: () => Promise<BrowserWindow>
noticeWindow: NoticeWindowManager
onWindowCreated?: (window: BrowserWindow) => void
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n'
import type { ServerChannel } from '../../../services/airi/channel-server'
import type { NoticeWindowManager } from '../../notice'
import type { SettingsWindowManager } from '../../settings'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
@@ -14,7 +15,7 @@ import { setupBaseWindowElectronInvokes } from '../../shared/window'
export async function setupDashboardWindowElectronInvokes(params: {
window: BrowserWindow
settingsWindow: () => Promise<BrowserWindow>
settingsWindow: SettingsWindowManager
chatWindow: () => Promise<BrowserWindow>
noticeWindow: NoticeWindowManager
i18n: I18n
@@ -30,7 +31,7 @@ export async function setupDashboardWindowElectronInvokes(params: {
await setupBaseWindowElectronInvokes({ context, window: params.window, serverChannel: params.serverChannel, i18n: params.i18n })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route))
defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow()))
defineInvokeHandler(context, noticeWindowEventa.openWindow, payload => params.noticeWindow.open(payload))
}
@@ -7,6 +7,7 @@ import type { McpStdioManager } from '../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../services/electron/auto-updater'
import type { NoticeWindowManager } from '../notice'
import type { OnboardingWindowManager } from '../onboarding'
import type { SettingsWindowManager } from '../settings'
import type { WidgetsWindowManager } from '../widgets'
import { dirname, join, resolve } from 'node:path'
@@ -46,7 +47,7 @@ const appConfigSchema = object({
type AppConfig = InferOutput<typeof appConfigSchema>
export async function setupMainWindow(params: {
settingsWindow: () => Promise<BrowserWindow>
settingsWindow: SettingsWindowManager
chatWindow: () => Promise<BrowserWindow>
widgetsManager: WidgetsWindowManager
noticeWindow: NoticeWindowManager
@@ -6,6 +6,7 @@ import type { McpStdioManager } from '../../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../../services/electron/auto-updater'
import type { NoticeWindowManager } from '../../notice'
import type { OnboardingWindowManager } from '../../onboarding'
import type { SettingsWindowManager } from '../../settings'
import type { WidgetsWindowManager } from '../../widgets'
import { defineInvokeHandler } from '@moeru/eventa'
@@ -22,7 +23,7 @@ import { setupBaseWindowElectronInvokes } from '../../shared/window'
export async function setupMainWindowElectronInvokes(params: {
window: BrowserWindow
settingsWindow: () => Promise<BrowserWindow>
settingsWindow: SettingsWindowManager
chatWindow: () => Promise<BrowserWindow>
widgetsManager: WidgetsWindowManager
noticeWindow: NoticeWindowManager
@@ -46,7 +47,7 @@ export async function setupMainWindowElectronInvokes(params: {
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route))
defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow()))
defineInvokeHandler(context, noticeWindowEventa.openWindow, payload => params.noticeWindow.open(payload))
}
@@ -12,10 +12,17 @@ import { BrowserWindow, shell } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { electronSettingsNavigate } from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { toggleWindowShow } from '../shared'
import { setupSettingsWindowInvokes } from './rpc/index.electron'
export interface SettingsWindowManager {
getWindow: () => Promise<BrowserWindow>
openWindow: (route?: string) => Promise<void>
}
export function setupSettingsWindowReusableFunc(params: {
widgetsManager: WidgetsWindowManager
autoUpdater: AutoUpdater
@@ -24,8 +31,13 @@ export function setupSettingsWindowReusableFunc(params: {
serverChannel: ServerChannel
mcpStdioManager: McpStdioManager
i18n: I18n
}) {
return createReusableWindow(async () => {
}): SettingsWindowManager {
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
const defaultRoute = '/settings'
let currentRoute = defaultRoute
let settingsContext: Awaited<ReturnType<typeof setupSettingsWindowInvokes>> | undefined
const reusable = createReusableWindow(async () => {
const window = new BrowserWindow({
title: 'Settings',
width: 600.0,
@@ -48,8 +60,8 @@ export function setupSettingsWindowReusableFunc(params: {
return { action: 'deny' }
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/settings'))
await setupSettingsWindowInvokes({
await load(window, withHashRoute(rendererBase, currentRoute))
settingsContext = await setupSettingsWindowInvokes({
settingsWindow: window,
widgetsManager: params.widgetsManager,
autoUpdater: params.autoUpdater,
@@ -59,8 +71,32 @@ export function setupSettingsWindowReusableFunc(params: {
i18n: params.i18n,
})
window.on('closed', () => {
if (settingsContext)
settingsContext = undefined
})
initScreenCaptureForWindow(window)
return window
}).getWindow
})
async function openWindow(route?: string) {
if (route) {
currentRoute = route
}
const window = await reusable.getWindow()
if (route && settingsContext) {
settingsContext.emit(electronSettingsNavigate, { route })
}
toggleWindowShow(window)
}
return {
getWindow: reusable.getWindow,
openWindow,
}
}
@@ -43,4 +43,6 @@ export async function setupSettingsWindowInvokes(params: {
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {
await params.devtoolsMarkdownStressWindow.openWindow(payload?.route)
})
return context
}
+12 -4
View File
@@ -28,7 +28,6 @@ import {
electronGetServerChannelConfig,
electronMcpCallTool,
electronMcpListTools,
electronOpenSettings,
electronPluginInspect,
electronPluginList,
electronPluginLoad,
@@ -36,6 +35,7 @@ import {
electronPluginSetEnabled,
electronPluginUnload,
electronPluginUpdateCapability,
electronSettingsNavigate,
electronStartTrackMousePosition,
i18nSetLocale,
pluginProtocolListProviders,
@@ -105,6 +105,17 @@ watch(dark, () => updateThemeColor(), { immediate: true })
watch(route, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
context.value.on(electronSettingsNavigate, (event) => {
const targetRoute = event?.body?.route
if (!targetRoute || route.fullPath === targetRoute) {
return
}
void router.push(targetRoute).catch((error) => {
console.warn('Failed to navigate settings window:', error)
})
})
onMounted(async () => {
analyticsStore.initialize()
cardStore.initialize()
@@ -133,9 +144,6 @@ onMounted(async () => {
},
})
}
// Listen for open-settings IPC message from main process
defineInvokeHandler(context.value, electronOpenSettings, () => router.push('/settings'))
})
watch(themeColorsHue, () => {
@@ -120,7 +120,7 @@ function refreshWindow() {
<div v-if="expanded" border="1 neutral-200 dark:neutral-800" mb-2 flex flex-col gap-1 rounded-2xl p-2 backdrop-blur-xl class="bg-neutral-100/80 shadow-2xl shadow-black/20 dark:bg-neutral-900/80">
<div grid grid-cols-3 gap-2>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="openSettings">
<ControlButton :button-style="adjustStyleClasses.button" @click="openSettings({ route: '/settings' })">
<div i-solar:settings-minimalistic-outline :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
</ControlButton>
<template #tooltip>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ControlButtonTooltip from '../controls-island/control-button-tooltip.vue'
import ControlButton from '../controls-island/control-button.vue'
import { electronOpenSettings } from '../../../../shared/eventa'
const { t, te } = useI18n()
const { connected } = storeToRefs(useModsServerChannelStore())
const openSettings = useElectronEventaInvoke(electronOpenSettings)
const flickerDuration = ref('6.4s')
const flickerDelay = ref('0s')
const statusIslandSize = {
border: 'border-2',
icon: 'size-6',
padding: 'p-2.5',
} as const
const buttonStyle = computed(() => {
return [
statusIslandSize.border,
statusIslandSize.padding,
'transition-all duration-300 ease-in-out',
connected.value
? 'border-emerald-200/60 bg-white/85 hover:bg-emerald-50/90 dark:border-emerald-400/15 dark:bg-neutral-900/75 dark:hover:bg-neutral-900/88'
: 'border-amber-300/80 bg-amber-50/90 hover:bg-amber-100/90 dark:border-amber-400/30 dark:bg-amber-950/25 dark:hover:bg-amber-950/38',
]
})
const iconClasses = computed(() => {
return [
connected.value ? 'i-ph:wifi-high' : 'i-ph:wifi-slash status-island-lamp-flicker',
statusIslandSize.icon,
'shrink-0 transition-colors duration-300 ease-in-out',
connected.value
? 'text-emerald-600 dark:text-emerald-300'
: 'text-amber-600 dark:text-amber-300',
]
})
const iconStyle = computed(() => {
if (connected.value) {
return undefined
}
return {
'--status-island-flicker-delay': flickerDelay.value,
'--status-island-flicker-duration': flickerDuration.value,
}
})
const buttonLabel = computed(() => {
if (connected.value) {
return te('tamagotchi.stage.status-island.connected')
? t('tamagotchi.stage.status-island.connected')
: 'WebSocket connected'
}
return te('tamagotchi.stage.status-island.disconnected')
? t('tamagotchi.stage.status-island.disconnected')
: 'WebSocket disconnected'
})
const tooltipLabel = computed(() => {
const openSettingsLabel = te('tamagotchi.stage.status-island.open-settings')
? t('tamagotchi.stage.status-island.open-settings')
: 'Open WebSocket settings'
return `${buttonLabel.value}. ${openSettingsLabel}`
})
function randomizeFlicker(resetPhase = false) {
flickerDuration.value = `${(5.8 + Math.random() * 1.8).toFixed(2)}s`
if (resetPhase) {
flickerDelay.value = `${(-Math.random() * 5.4).toFixed(2)}s`
return
}
flickerDelay.value = '0s'
}
function handleFlickerIteration() {
if (!connected.value) {
randomizeFlicker()
}
}
watch(connected, (isConnected) => {
if (isConnected) {
flickerDelay.value = '0s'
return
}
randomizeFlicker(true)
}, { immediate: true })
</script>
<template>
<div fixed right-3 top-3 z-20>
<ControlButtonTooltip side="left">
<ControlButton
:button-style="buttonStyle.join(' ')"
:aria-label="tooltipLabel"
:title="tooltipLabel"
@click="openSettings({ route: '/settings/connection' })"
>
<div :class="iconClasses" :style="iconStyle" @animationiteration="handleFlickerIteration" />
</ControlButton>
<template #tooltip>
{{ tooltipLabel }}
</template>
</ControlButtonTooltip>
</div>
</template>
<style scoped>
@keyframes status-island-lamp-flicker {
0%,
6%,
22%,
33%,
52%,
68%,
86%,
100% {
opacity: 1;
}
3% {
opacity: 0.74;
}
9% {
opacity: 0.92;
}
13% {
opacity: 0.38;
}
18% {
opacity: 0.58;
}
27% {
opacity: 0.44;
}
29% {
opacity: 0.84;
}
41% {
opacity: 0.42;
}
45% {
opacity: 0.88;
}
57% {
opacity: 0.62;
}
61% {
opacity: 0.8;
}
73% {
opacity: 0.36;
}
74.4% {
opacity: 0.08;
}
75.2% {
opacity: 0.82;
}
78% {
opacity: 0.94;
}
91% {
opacity: 0.52;
}
}
.status-island-lamp-flicker {
animation-delay: var(--status-island-flicker-delay, 0s);
animation-duration: var(--status-island-flicker-duration, 6.4s);
animation-iteration-count: infinite;
animation-name: status-island-lamp-flicker;
animation-timing-function: ease-in-out;
filter: drop-shadow(0 0 0.14rem rgb(251 191 36 / 0.18));
will-change: opacity;
}
</style>
@@ -29,6 +29,7 @@ import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
import ControlsIsland from '../components/stage-islands/controls-island/index.vue'
import ResourceStatusIsland from '../components/stage-islands/resource-status-island/index.vue'
import StatusIsland from '../components/stage-islands/status-island/index.vue'
import { electronOpenOnboarding } from '../../shared/eventa'
import { useControlsIslandStore } from '../stores/controls-island'
@@ -37,6 +38,7 @@ import { useWindowStore } from '../stores/window'
import { shouldSampleStageTransparency } from '../utils/stage-three-transparency'
const controlsIslandRef = ref<InstanceType<typeof ControlsIsland>>()
const statusIslandRef = ref<InstanceType<typeof StatusIsland>>()
const widgetStageRef = ref<InstanceType<typeof WidgetStage>>()
const stageCanvas = toRef(() => widgetStageRef.value?.canvasElement())
const componentStateStage = ref<'pending' | 'loading' | 'mounted'>('pending')
@@ -51,7 +53,9 @@ const openOnboarding = useElectronEventaInvoke(electronOpenOnboarding)
const { isOutside: isOutsideWindow } = useElectronMouseInWindow()
const { isOutside } = useElectronMouseInElement(controlsIslandRef)
const { isOutside: isOutsideStatusIsland } = useElectronMouseInElement(statusIslandRef)
const isOutsideFor250Ms = refDebounced(isOutside, 250)
const isOutsideStatusIslandFor250Ms = refDebounced(isOutsideStatusIsland, 250)
const { x: relativeMouseX, y: relativeMouseY } = useElectronRelativeMouse()
// NOTICE: In real-world use cases of Fade on Hover feature, the cursor may move around the edge of the
// model rapidly, causing flickering effects when checking pixel transparency strictly.
@@ -108,7 +112,7 @@ const { pause, resume } = watch(isTransparent, (transparent) => {
const hearingDialogOpen = computed(() => controlsIslandRef.value?.hearingDialogOpen ?? false)
watch([isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, hearingDialogOpen, fadeOnHoverEnabled, stagePaused], () => {
watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, hearingDialogOpen, fadeOnHoverEnabled, stagePaused], () => {
if (stagePaused.value) {
isIgnoringMouseEvents.value = false
shouldFadeOnCursorWithin.value = false
@@ -126,7 +130,7 @@ watch([isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTrans
return
}
const insideControls = !isOutsideFor250Ms.value
const insideControls = !isOutsideFor250Ms.value || !isOutsideStatusIslandFor250Ms.value
const nearBorder = isAroundWindowBorderFor250Ms.value
if (insideControls || nearBorder) {
@@ -371,6 +375,7 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
'transition-opacity duration-250 ease-in-out',
]"
>
<StatusIsland ref="statusIslandRef" />
<ResourceStatusIsland />
<WidgetStage
ref="widgetStageRef"
+2 -1
View File
@@ -17,7 +17,8 @@ export const electronStartTrackMousePosition = defineInvokeEventa('eventa:invoke
export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:electron:start-dragging-window')
export const electronOpenMainDevtools = defineInvokeEventa('eventa:invoke:electron:windows:main:devtools:open')
export const electronOpenSettings = defineInvokeEventa('eventa:invoke:electron:windows:settings:open')
export const electronOpenSettings = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:settings:open')
export const electronSettingsNavigate = defineEventa<{ route: string }>('eventa:event:electron:windows:settings:navigate')
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')
@@ -24,6 +24,10 @@ docs:
'close': Close
'expand': Expand
'collapse': Collapse
'status-island':
connected: WebSocket connected
disconnected: WebSocket disconnected
'open-settings': Open WebSocket settings
notice:
'fade-on-hover':
@@ -24,6 +24,10 @@ docs:
'close': 关闭
'expand': 展开
'collapse': 折叠
'status-island':
connected: WebSocket 已连接
disconnected: WebSocket 已断开
'open-settings': 打开 WebSocket 设置
notice:
'fade-on-hover':
title: 悬停淡出
+28 -3
View File
@@ -174,10 +174,22 @@ export class Client<C = undefined> {
const ws = new WebSocket(this.opts.url)
this.websocket = ws
const isCurrentSocket = () => this.websocket === ws
ws.onmessage = this.handleMessageBound
ws.onmessage = (event: MessageEvent) => {
if (!isCurrentSocket()) {
return
}
this.handleMessageBound(event)
}
ws.onerror = (event: any) => {
if (!isCurrentSocket()) {
return
}
settle(() => {
this.websocket = undefined
this.connected = false
this.opts.onError?.(event)
@@ -185,6 +197,12 @@ export class Client<C = undefined> {
})
}
ws.onclose = () => {
if (!isCurrentSocket()) {
return
}
this.websocket = undefined
if (!settled && !this.connected) {
settle(() => {
reject(new Error('WebSocket closed before open'))
@@ -202,6 +220,10 @@ export class Client<C = undefined> {
}
}
ws.onopen = () => {
if (!isCurrentSocket()) {
return
}
settle(() => {
this.connected = true
@@ -355,8 +377,10 @@ export class Client<C = undefined> {
close(): void {
this.shouldClose = true
this.stopHeartbeat()
if (this.websocket) {
this.websocket.close()
const websocket = this.websocket
this.websocket = undefined
if (websocket) {
websocket.close()
this.connected = false
}
}
@@ -425,6 +449,7 @@ export class Client<C = undefined> {
const ws = this.websocket
this.connected = false
this.websocket = undefined
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
ws.close()
}