feat(stage-tamagotchi):add spotlight (#1959)
Some checks failed
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
CI / Unit Test (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
Cloudflare Pages (Admin UI) / Deploy - ui-admin (push) Waiting to run
Cloudflare Pages (Auth UI) / Deploy - ui-server-auth (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run
Update Nix pnpmDeps Hash / update (push) Has been cancelled

This commit is contained in:
Doji 2026-06-11 20:53:35 +02:00 committed by GitHub
parent 668440a732
commit 8b98a2c138
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1287 additions and 337 deletions

View file

@ -1,9 +1,15 @@
import { object, optional, picklist, string } from 'valibot'
import { array, object, optional, picklist, string } from 'valibot'
import { createConfig } from '../libs/electron/persistence'
const shortcutAcceleratorSchema = object({
modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])),
key: string(),
})
export const globalAppConfigSchema = object({
language: optional(string()),
spotlightShortcutAccelerator: optional(shortcutAcceleratorSchema),
updateChannel: optional(picklist(['latest', 'stable', 'alpha', 'beta', 'nightly', 'canary'])),
})

View file

@ -48,6 +48,7 @@ import { setupMainWindow } from './windows/main'
import { setupNoticeWindowManager } from './windows/notice'
import { setupOnboardingWindowManager } from './windows/onboarding'
import { setupSettingsWindowReusableFunc } from './windows/settings'
import { setupSpotlightWindowManager } from './windows/spotlight'
import { setupWidgetsWindowManager } from './windows/widgets'
// TODO: once we refactored eventa to support window-namespaced contexts,
@ -203,8 +204,13 @@ app.whenReady().then(async () => {
build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn),
})
const spotlightWindow = injeca.provide('windows:spotlight', {
dependsOn: { serverChannel, i18n, chatWindow, globalShortcut, appConfig },
build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn),
})
const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager, globalShortcut },
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager, globalShortcut, spotlightWindow },
build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
})
@ -245,7 +251,7 @@ app.whenReady().then(async () => {
}
injeca.invoke({
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, artistryConfig },
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, spotlightWindow, artistryConfig },
callback: async (deps) => {
const { context } = createContext(ipcMain)
await setupArtistryBridge({

View file

@ -219,6 +219,61 @@ describe('setupGlobalShortcutService', () => {
expect(m.unregisterMock).not.toHaveBeenCalled()
})
it('rebinds main-owned shortcuts transactionally', async () => {
const m = await setupMocks()
const service = m.setupGlobalShortcutService()
service.registerMainShortcut({
binding: exampleBinding('spotlight', 'KeyA'),
onTriggered: vi.fn(),
})
const secondTriggered = vi.fn()
const success = service.registerMainShortcut({
binding: exampleBinding('spotlight', 'KeyB'),
onTriggered: secondTriggered,
})
expect(success).toEqual({ id: 'spotlight', ok: true })
expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+A')
m.triggerCallbacks.get('CmdOrCtrl+Shift+B')?.()
expect(secondTriggered).toHaveBeenCalledTimes(1)
const oldTriggered = vi.fn()
service.registerMainShortcut({ binding: exampleBinding('spotlight', 'KeyA'), onTriggered: oldTriggered })
m.unregisterMock.mockClear()
m.registerMock.mockImplementationOnce(() => false)
const result = service.registerMainShortcut({
binding: exampleBinding('spotlight', 'KeyC'),
onTriggered: vi.fn(),
})
expect(result).toEqual({ id: 'spotlight', ok: false, reason: ShortcutFailureReasons.Conflict })
expect(m.unregisterMock).not.toHaveBeenCalled()
m.triggerCallbacks.get('CmdOrCtrl+Shift+A')?.()
expect(oldTriggered).toHaveBeenCalledTimes(1)
})
it('replaces the callback when rebinding a main-owned shortcut to the same accelerator', async () => {
const m = await setupMocks()
const service = m.setupGlobalShortcutService()
const oldTriggered = vi.fn()
const nextTriggered = vi.fn()
service.registerMainShortcut({
binding: exampleBinding('spotlight', 'KeyA'),
onTriggered: oldTriggered,
})
const result = service.registerMainShortcut({
binding: exampleBinding('spotlight', 'KeyA'),
onTriggered: nextTriggered,
})
expect(result).toEqual({ id: 'spotlight', ok: true })
expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+A')
m.triggerCallbacks.get('CmdOrCtrl+Shift+A')?.()
expect(oldTriggered).not.toHaveBeenCalled()
expect(nextTriggered).toHaveBeenCalledTimes(1)
})
it('allows re-register after explicit unregister', async () => {
const m = await setupMocks()
const service = m.setupGlobalShortcutService()

View file

@ -24,21 +24,21 @@ export interface RegisterWindowParams {
window: BrowserWindow
}
export interface RegisterMainShortcutParams {
binding: ShortcutBinding
onTriggered: () => void
}
export interface GlobalShortcutService {
/**
* Register a per-window eventa context. Invoke handlers are installed
* on the context; trigger events are broadcast to every registered
* context, so each window's renderer receives them. Auto-removes on
* `window.on('closed')`.
*/
registerWindow: (params: RegisterWindowParams) => void
registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult
dispose: () => void
}
type ActiveBinding = { binding: ShortcutBinding } & (
| { driver: 'electron', electronAccelerator: string }
| { driver: 'uiohook' }
)
type ActiveBinding
= | { binding: ShortcutBinding, owner: 'renderer', driver: 'electron', electronAccelerator: string }
| { binding: ShortcutBinding, owner: 'main', driver: 'electron', electronAccelerator: string, onTriggered: () => void }
| { binding: ShortcutBinding, owner: 'renderer', driver: 'uiohook' }
export function setupGlobalShortcutService(): GlobalShortcutService {
const log = useLogg('global-shortcut').useGlobalConfig()
@ -75,32 +75,49 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict }
}
active.set(binding.id, { binding, driver: 'electron', electronAccelerator })
active.set(binding.id, { binding, owner: 'renderer', driver: 'electron', electronAccelerator })
return { id: binding.id, ok: true }
}
function tryRegisterUiohook(binding: ShortcutBinding): ShortcutRegistrationResult {
const result = uiohookDriver.tryRegister(binding)
if (result.ok)
active.set(binding.id, { binding, driver: 'uiohook' })
active.set(binding.id, { binding, owner: 'renderer', driver: 'uiohook' })
return result
}
function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult {
if (active.has(binding.id)) {
// Main-owned shortcuts stay live without a renderer context.
function registerMainShortcut({ binding, onTriggered }: RegisterMainShortcutParams): ShortcutRegistrationResult {
const existing = active.get(binding.id)
if (existing && existing.owner !== 'main')
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
const electronAccelerator = formatElectronAccelerator(binding.accelerator)
const nextEntry: ActiveBinding = { binding, owner: 'main', driver: 'electron', electronAccelerator, onTriggered }
if (existing?.electronAccelerator === electronAccelerator) {
releaseEntry(binding.id, existing)
if (globalShortcut.register(electronAccelerator, onTriggered)) {
active.set(binding.id, nextEntry)
return { id: binding.id, ok: true }
}
if (globalShortcut.register(existing.electronAccelerator, existing.onTriggered))
active.set(binding.id, existing)
else
log.warn(`Failed to restore main-owned shortcut "${binding.id}" after rebinding failure`)
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict }
}
return binding.receiveKeyUps
? tryRegisterUiohook(binding)
: tryRegisterElectron(binding)
if (!globalShortcut.register(electronAccelerator, onTriggered))
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict }
if (existing)
releaseEntry(binding.id, existing)
active.set(binding.id, nextEntry)
return { id: binding.id, ok: true }
}
function unregisterById(id: string): void {
const entry = active.get(id)
if (!entry)
return
function releaseEntry(id: string, entry: ActiveBinding): void {
if (entry.driver === 'electron') {
try {
globalShortcut.unregister(entry.electronAccelerator)
@ -115,19 +132,31 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
active.delete(id)
}
function unregisterAll(): void {
for (const [id, entry] of active) {
if (entry.driver !== 'electron')
continue
try {
globalShortcut.unregister(entry.electronAccelerator)
}
catch (error) {
log.withError(error).warn(`Failed to unregister accelerator for "${id}"`)
}
function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult {
if (active.has(binding.id)) {
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
}
return binding.receiveKeyUps
? tryRegisterUiohook(binding)
: tryRegisterElectron(binding)
}
function unregisterById(id: string): void {
const entry = active.get(id)
if (!entry || entry.owner === 'main')
return
releaseEntry(id, entry)
}
// Renderer resets must not drop main-owned shortcuts such as Spotlight.
function unregisterAll(includeMainOwned = false): void {
for (const [id, entry] of active) {
if (!includeMainOwned && entry.owner === 'main')
continue
releaseEntry(id, entry)
}
uiohookDriver.unregisterAll()
active.clear()
}
const registerWindow: GlobalShortcutService['registerWindow'] = ({ context, window }) => {
@ -159,12 +188,12 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
}
const dispose: GlobalShortcutService['dispose'] = () => {
unregisterAll()
unregisterAll(true)
uiohookDriver.dispose()
contexts.clear()
}
onAppBeforeQuit(() => dispose())
return { registerWindow, dispose }
return { registerWindow, registerMainShortcut, dispose }
}

View file

@ -6,6 +6,7 @@ import type { McpStdioManager } from '../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../services/electron/auto-updater'
import type { GlobalShortcutService } from '../../services/electron/global-shortcut'
import type { DevtoolsWindowManager } from '../devtools'
import type { SpotlightWindowManager } from '../spotlight'
import type { WidgetsWindowManager } from '../widgets'
import { join, resolve } from 'node:path'
@ -37,6 +38,7 @@ export function setupSettingsWindowReusableFunc(params: {
i18n: I18n
windowAuthManager: WindowAuthManager
globalShortcut: GlobalShortcutService
spotlightWindow: SpotlightWindowManager
}): SettingsWindowManager {
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
const defaultRoute = '/settings'
@ -77,6 +79,7 @@ export function setupSettingsWindowReusableFunc(params: {
i18n: params.i18n,
windowAuthManager: params.windowAuthManager,
globalShortcut: params.globalShortcut,
spotlightWindow: params.spotlightWindow,
})
await load(window, withHashRoute(rendererBase, currentRoute))

View file

@ -8,13 +8,19 @@ import type { McpStdioManager } from '../../../services/airi/mcp-servers'
import type { AutoUpdater } from '../../../services/electron/auto-updater'
import type { GlobalShortcutService } from '../../../services/electron/global-shortcut'
import type { DevtoolsWindowManager } from '../../devtools'
import type { SpotlightWindowManager } from '../../spotlight'
import type { WidgetsWindowManager } from '../../widgets'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa'
import {
electronOpenDevtoolsWindow,
electronOpenSettingsDevtools,
electronSpotlightShortcutGet,
electronSpotlightShortcutSet,
} from '../../../../shared/eventa'
import { createAuthService } from '../../../services/airi/auth'
import { createGodotStageService } from '../../../services/airi/godot-stage'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
@ -33,6 +39,7 @@ export async function setupSettingsWindowInvokes(params: {
i18n: I18n
windowAuthManager: WindowAuthManager
globalShortcut: GlobalShortcutService
spotlightWindow: SpotlightWindowManager
}) {
// 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
@ -52,6 +59,14 @@ export async function setupSettingsWindowInvokes(params: {
// Register the global shortcut service for the settings window.
params.globalShortcut.registerWindow({ context, window: params.settingsWindow })
defineInvokeHandler(context, electronSpotlightShortcutGet, () => params.spotlightWindow.getShortcutAccelerator())
defineInvokeHandler(context, electronSpotlightShortcutSet, (payload) => {
if (payload?.accelerator === undefined)
throw new TypeError('electronSpotlightShortcutSet called with invalid payload')
return params.spotlightWindow.updateShortcutAccelerator(payload.accelerator)
})
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {
await params.devtoolsWindow.openWindow(payload)

View file

@ -0,0 +1,212 @@
import type { ShortcutAccelerator, ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut'
import type { globalAppConfigSchema } from '../../configs/global'
import type { Config } from '../../libs/electron/persistence'
import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
import type { GlobalShortcutService } from '../../services/electron/global-shortcut'
import { join, resolve } from 'node:path'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
import { BrowserWindow, ipcMain, Notification, screen } from 'electron'
import { isMacOS } from 'std-env'
import icon from '../../../../resources/icon.png?asset'
import {
electronSpotlightHide,
electronSpotlightShowResultNotification,
} from '../../../shared/eventa'
import { isSafeSpotlightAccelerator } from '../../../shared/spotlight-shortcut'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { setupBaseWindowElectronInvokes, transparentWindowConfig } from '../shared/window'
const SPOTLIGHT_WINDOW_WIDTH = 720
const SPOTLIGHT_WINDOW_HEIGHT = 100
const SPOTLIGHT_SHORTCUT_ID = 'spotlight'
const defaultSpotlightAccelerator: ShortcutAccelerator = { modifiers: ['ctrl', 'shift'], key: 'KeyA' }
export interface SpotlightWindowManager {
show: () => Promise<void>
getShortcutAccelerator: () => ShortcutAccelerator
updateShortcutAccelerator: (accelerator: ShortcutAccelerator | null) => ReturnType<GlobalShortcutService['registerMainShortcut']>
}
function resolveSpotlightBounds() {
const cursorPoint = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPoint)
const { x, y, width } = display.workArea
return {
x: Math.round(x + (width - SPOTLIGHT_WINDOW_WIDTH) / 2),
y: Math.round(y + display.workArea.height * 0.22),
width: SPOTLIGHT_WINDOW_WIDTH,
height: SPOTLIGHT_WINDOW_HEIGHT,
}
}
export function setupSpotlightWindowManager(params: {
serverChannel: ServerChannel
i18n: I18n
chatWindow: () => Promise<BrowserWindow>
globalShortcut: GlobalShortcutService
appConfig: Config<typeof globalAppConfigSchema>
}): SpotlightWindowManager {
const log = useLogg('spotlight-window').useGlobalConfig()
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
// NOTICE:
// Electron may GC a `Notification` once the constructor scope returns, which
// silently drops its `click` handler before the user interacts. Hold a strong
// reference until the notification is dismissed (`click` / `close`) or fails.
const resultNotifications = new Set<Notification>()
async function openChatWindowFromNotification() {
try {
const window = await params.chatWindow()
if (window.isMinimized())
window.restore()
window.show()
window.focus()
window.moveTop()
}
catch (error) {
log.withError(error).warn('Failed to open Chat window from Spotlight notification')
}
}
function showNotification(body: string, onClick?: () => void) {
const notification = new Notification({
title: 'AIRI',
body,
...(onClick && !isMacOS ? { timeoutType: 'never' as const } : {}),
})
resultNotifications.add(notification)
const release = () => resultNotifications.delete(notification)
notification.once('close', release)
notification.once('failed', release)
notification.once('click', () => {
release()
onClick?.()
})
notification.show()
}
const reusable = createReusableWindow(async () => {
const window = new BrowserWindow({
...transparentWindowConfig(),
titleBarStyle: undefined,
title: 'Spotlight',
width: SPOTLIGHT_WINDOW_WIDTH,
height: SPOTLIGHT_WINDOW_HEIGHT,
show: false,
resizable: false,
maximizable: false,
minimizable: false,
skipTaskbar: true,
alwaysOnTop: true,
icon,
webPreferences: {
preload: join(getElectronMainDirname(), '../preload/index.mjs'),
sandbox: false,
},
})
window.on('blur', () => window.hide())
const { context } = createContext(ipcMain, window)
await setupBaseWindowElectronInvokes({ context, window, i18n: params.i18n, serverChannel: params.serverChannel })
// Only the Spotlight window may call these private invokes.
const isFromSpotlightWindow = (senderId?: number) => window.webContents.id === senderId
defineInvokeHandler(context, electronSpotlightHide, (_, options) => {
if (isFromSpotlightWindow(options?.raw.ipcMainEvent.sender.id))
window.hide()
})
defineInvokeHandler(context, electronSpotlightShowResultNotification, (payload, options) => {
if (!payload || !isFromSpotlightWindow(options?.raw.ipcMainEvent.sender.id))
return
showNotification(payload.body, () => void openChatWindowFromNotification())
})
await load(window, withHashRoute(rendererBase, '/spotlight'))
return window
})
async function show() {
const window = await reusable.getWindow()
window.setBounds(resolveSpotlightBounds())
window.show()
window.focus()
window.webContents.focus()
}
function getShortcutAccelerator(): ShortcutAccelerator {
return params.appConfig.get()?.spotlightShortcutAccelerator ?? defaultSpotlightAccelerator
}
function createShortcutBinding(accelerator = getShortcutAccelerator()): ShortcutBinding {
return {
id: SPOTLIGHT_SHORTCUT_ID,
accelerator,
scope: 'global',
description: 'Spotlight',
}
}
function handleShortcutTriggered() {
void show().catch((error) => {
log.withError(error).warn('Failed to show Spotlight window')
})
}
function updateShortcutAccelerator(accelerator: ShortcutAccelerator | null) {
const nextAccelerator = accelerator ?? defaultSpotlightAccelerator
if (!isSafeSpotlightAccelerator(nextAccelerator))
return { id: SPOTLIGHT_SHORTCUT_ID, ok: false as const, reason: ShortcutFailureReasons.Invalid }
const registration = params.globalShortcut.registerMainShortcut({
binding: createShortcutBinding(nextAccelerator),
onTriggered: handleShortcutTriggered,
})
if (registration.ok) {
params.appConfig.update({
...params.appConfig.get(),
spotlightShortcutAccelerator: nextAccelerator,
})
}
else {
log.warn(`Failed to update Spotlight shortcut: ${registration.reason}`)
}
return registration.ok ? { ...registration, actualAccelerator: nextAccelerator } : registration
}
// Main-owned so renderer `unregisterAll` resets do not drop Spotlight.
const shortcutResult = params.globalShortcut.registerMainShortcut({
binding: createShortcutBinding(),
onTriggered: handleShortcutTriggered,
})
if (!shortcutResult.ok) {
log.warn(`Failed to register Spotlight shortcut: ${shortcutResult.reason}`)
showNotification(params.i18n.t('tamagotchi.spotlight.errors.shortcutRegistrationFailed'))
}
return {
getShortcutAccelerator,
show,
updateShortcutAccelerator,
}
}

View file

@ -51,151 +51,223 @@ import {
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
import { useLanguage } from './composables/use-language'
import { createChatSyncWindowLifecycle } from './stores/chat-sync-lifecycle'
import {
createChatSyncWindowLifecycle,
resolveInitialChatSyncRoutePath,
} from './stores/chat-sync-lifecycle'
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
import { useTamagotchiPluginToolsStore } from './stores/plugin-tools'
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
import { useStageWindowLifecycleStore } from './stores/stage-window-lifecycle'
const { isDark: dark } = useTheme()
const contextBridgeStore = useContextBridgeStore()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const { language, themeColorsHue, themeColorsHueDynamic } = storeToRefs(settingsStore)
const serverChannelSettingsStore = useServerChannelSettingsStore()
const router = useRouter()
const route = useRoute()
const cardStore = useAiriCardStore()
const chatSessionStore = useChatSessionStore()
const serverChannelStore = useModsServerChannelStore()
const characterOrchestratorStore = useCharacterOrchestratorStore()
const analyticsStore = useSharedAnalyticsStore()
const inferencePreload = useInferencePreload()
const pluginHostInspectorStore = usePluginHostInspectorStore()
const mcpToolsStore = useTamagotchiMcpToolsStore()
const pluginToolsStore = useTamagotchiPluginToolsStore()
const stageWindowLifecycleStore = useStageWindowLifecycleStore()
const settingsAudioDeviceStore = useSettingsAudioDevice()
const artistryStore = useArtistryStore()
const { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore)
const context = useElectronEventaContext()
usePerfTracerBridgeStore()
initializeStageThreeRuntimeTraceBridge()
initializeElectronAuthCallbackBridge()
void stageWindowLifecycleStore.initializeWindowLifecycleBridge()
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
const listPlugins = useElectronEventaInvoke(electronPluginList)
const setPluginEnabled = useElectronEventaInvoke(electronPluginSetEnabled)
const setPluginAutoReload = useElectronEventaInvoke(electronPluginSetAutoReload)
const loadEnabledPlugins = useElectronEventaInvoke(electronPluginLoadEnabled)
const loadPlugin = useElectronEventaInvoke(electronPluginLoad)
const unloadPlugin = useElectronEventaInvoke(electronPluginUnload)
const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition)
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
const getMainLocale = useElectronEventaInvoke(i18nGetLocale)
const setLocale = useElectronEventaInvoke(i18nSetLocale)
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
const initialWindowRoutePath = resolveInitialChatSyncRoutePath(route.path)
const chatSyncLifecycle = createChatSyncWindowLifecycle(route.path)
const isChatWindowRoute = () => route.path === '/chat'
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
const isWidgetsWindowRoute = () => route.path === '/widgets'
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
const isSettingsWindowRoute = initialWindowRoutePath.startsWith('/settings')
function syncGodotStageRenderer(state: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error' }) {
if (state.state === 'running') {
settingsStore.setStageModelRenderer('godot')
return
function createFullStageRuntime() {
const contextBridgeStore = useContextBridgeStore()
const displayModelsStore = useDisplayModelsStore()
const serverChannelSettingsStore = useServerChannelSettingsStore()
const cardStore = useAiriCardStore()
const serverChannelStore = useModsServerChannelStore()
const characterOrchestratorStore = useCharacterOrchestratorStore()
const analyticsStore = useSharedAnalyticsStore()
const inferencePreload = useInferencePreload()
const pluginHostInspectorStore = usePluginHostInspectorStore()
const mcpToolsStore = useTamagotchiMcpToolsStore()
const pluginToolsStore = useTamagotchiPluginToolsStore()
const stageWindowLifecycleStore = useStageWindowLifecycleStore()
const settingsAudioDeviceStore = useSettingsAudioDevice()
const artistryStore = useArtistryStore()
const { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore)
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
const listPlugins = useElectronEventaInvoke(electronPluginList)
const setPluginEnabled = useElectronEventaInvoke(electronPluginSetEnabled)
const setPluginAutoReload = useElectronEventaInvoke(electronPluginSetAutoReload)
const loadEnabledPlugins = useElectronEventaInvoke(electronPluginLoadEnabled)
const loadPlugin = useElectronEventaInvoke(electronPluginLoad)
const unloadPlugin = useElectronEventaInvoke(electronPluginUnload)
const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition)
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
const isAuxiliaryChatRoute = initialWindowRoutePath === '/chat'
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
const isWidgetsWindowRoute = () => route.path === '/widgets'
function syncGodotStageRenderer(state: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error' }) {
if (state.state === 'running') {
settingsStore.setStageModelRenderer('godot')
return
}
if ((state.state === 'stopped' || state.state === 'error') && settingsStore.stageModelRenderer === 'godot')
settingsStore.restoreBuiltInStageModelRenderer()
}
if ((state.state === 'stopped' || state.state === 'error') && settingsStore.stageModelRenderer === 'godot')
settingsStore.restoreBuiltInStageModelRenderer()
}
async function refreshPluginRuntimeTools() {
try {
await pluginToolsStore.refresh()
async function refreshPluginRuntimeTools() {
try {
await pluginToolsStore.refresh()
}
catch (error) {
console.warn('[App] Failed to refresh plugin runtime tools:', error)
}
}
catch (error) {
console.warn('[App] Failed to refresh plugin runtime tools:', error)
usePerfTracerBridgeStore()
initializeStageThreeRuntimeTraceBridge()
initializeElectronAuthCallbackBridge()
void stageWindowLifecycleStore.initializeWindowLifecycleBridge()
watch(() => route.path, () => {
contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindowRoute() ? 'client' : 'main')
}, { immediate: true })
// NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers.
pluginHostInspectorStore.setBridge({
list: () => listPlugins(),
setEnabled: async (payload) => {
const result = await setPluginEnabled(payload)
await refreshPluginRuntimeTools()
return result
},
setAutoReload: payload => setPluginAutoReload(payload),
loadEnabled: async () => {
const result = await loadEnabledPlugins()
await refreshPluginRuntimeTools()
return result
},
load: async (payload) => {
const result = await loadPlugin(payload)
await refreshPluginRuntimeTools()
return result
},
unload: async (payload) => {
const result = await unloadPlugin(payload)
await refreshPluginRuntimeTools()
return result
},
inspect: () => inspectPluginHost(),
})
// NOTICE: Runtime tool stores must register during setup so renderer consumers can see them
// before `onMounted()` finishes the rest of the startup flow.
void mcpToolsStore.refresh().catch((error) => {
console.warn('[App] Failed to refresh MCP runtime tools:', error)
})
void refreshPluginRuntimeTools()
watch([activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions], () => {
if (activeProvider.value) {
void syncArtistryConfig({
provider: activeProvider.value as string,
globals: JSON.parse(JSON.stringify(artistryGlobals.value)),
model: activeModel.value,
promptPrefix: defaultPromptPrefix.value,
options: providerOptions.value,
})
}
}, { deep: true, immediate: true })
context.value.on(electronGodotStageStatusChanged, (event) => {
if (!event.body) {
return
}
syncGodotStageRenderer(event.body)
})
return {
async initialize() {
analyticsStore.initialize()
await displayModelsStore.initialize()
cardStore.initialize()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
await settingsAudioDeviceStore.initialize()
if (isGodotStageRoute()) {
try {
syncGodotStageRenderer(await getGodotStageStatus())
}
catch (error) {
console.warn('[App] Failed to fetch Godot stage status:', error)
}
}
const serverChannelConfig = await getServerChannelConfig()
serverChannelSettingsStore.tlsConfig = serverChannelConfig.tlsConfig ?? null
serverChannelSettingsStore.hostname = serverChannelConfig.hostname
serverChannelSettingsStore.authToken = serverChannelConfig.authToken
await serverChannelStore.initialize({
token: serverChannelConfig.authToken || undefined,
possibleEvents: ['ui:configure'],
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
if (!isAuxiliaryChatRoute) {
contextBridgeStore.initialize()
if (!isWidgetsWindowRoute()) {
characterOrchestratorStore.initialize()
await startTrackingCursorPoint()
}
}
defineInvokeHandler(context.value, pluginProtocolListProviders, async () => listProvidersForPluginHost())
if (shouldPublishPluginHostCapabilities()) {
await reportPluginCapability({
key: pluginProtocolListProvidersEventName,
state: 'ready',
metadata: {
source: 'stage-ui',
},
})
}
inferencePreload.triggerPreload()
},
dispose() {
if (!isAuxiliaryChatRoute)
contextBridgeStore.dispose()
mcpToolsStore.dispose()
pluginToolsStore.dispose()
},
}
}
watch(() => route.path, () => {
contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindowRoute() ? 'client' : 'main')
}, { immediate: true })
// NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers.
pluginHostInspectorStore.setBridge({
list: () => listPlugins(),
setEnabled: async (payload) => {
const result = await setPluginEnabled(payload)
await refreshPluginRuntimeTools()
return result
},
setAutoReload: payload => setPluginAutoReload(payload),
loadEnabled: async () => {
const result = await loadEnabledPlugins()
await refreshPluginRuntimeTools()
return result
},
load: async (payload) => {
const result = await loadPlugin(payload)
await refreshPluginRuntimeTools()
return result
},
unload: async (payload) => {
const result = await unloadPlugin(payload)
await refreshPluginRuntimeTools()
return result
},
inspect: () => inspectPluginHost(),
})
// NOTICE: Runtime tool stores must register during setup so renderer consumers can see them
// before `onMounted()` finishes the rest of the startup flow.
void mcpToolsStore.refresh().catch((error) => {
console.warn('[App] Failed to refresh MCP runtime tools:', error)
})
void refreshPluginRuntimeTools()
const fullStageRuntime = isSpotlightWindowRoute ? null : createFullStageRuntime()
const { restore: restoreLocale } = useLanguage(language, getMainLocale, setLocale)
watch([activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions], () => {
if (activeProvider.value) {
void syncArtistryConfig({
provider: activeProvider.value as string,
globals: JSON.parse(JSON.stringify(artistryGlobals.value)),
model: activeModel.value,
promptPrefix: defaultPromptPrefix.value,
options: providerOptions.value,
})
}
}, { deep: true, immediate: true })
const { updateThemeColor } = useThemeColor(themeColorFromValue({ light: 'rgb(255 255 255)', dark: 'rgb(18 18 18)' }))
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
}
if (isSettingsWindowRoute) {
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)
void router.push(targetRoute).catch((error) => {
console.warn('Failed to navigate settings window:', error)
})
})
})
context.value.on(electronGodotStageStatusChanged, (event) => {
if (!event.body) {
return
}
syncGodotStageRenderer(event.body)
})
}
onMounted(async () => {
chatSyncLifecycle.initialize()
@ -208,56 +280,9 @@ onMounted(async () => {
// https://github.com/moeru-ai/airi/issues/1658
await restoreLocale()
analyticsStore.initialize()
await displayModelsStore.initialize()
cardStore.initialize()
await chatSessionStore.initialize()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
await settingsAudioDeviceStore.initialize()
if (isGodotStageRoute()) {
try {
syncGodotStageRenderer(await getGodotStageStatus())
}
catch (error) {
console.warn('[App] Failed to fetch Godot stage status:', error)
}
}
const serverChannelConfig = await getServerChannelConfig()
serverChannelSettingsStore.tlsConfig = serverChannelConfig.tlsConfig ?? null
serverChannelSettingsStore.hostname = serverChannelConfig.hostname
serverChannelSettingsStore.authToken = serverChannelConfig.authToken
await serverChannelStore.initialize({
token: serverChannelConfig.authToken || undefined,
possibleEvents: ['ui:configure'],
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
if (!isChatWindowRoute()) {
contextBridgeStore.initialize()
if (!isWidgetsWindowRoute()) {
characterOrchestratorStore.initialize()
await startTrackingCursorPoint()
}
}
// Expose stage provider definitions to plugin host APIs.
defineInvokeHandler(context.value, pluginProtocolListProviders, async () => listProvidersForPluginHost())
if (shouldPublishPluginHostCapabilities()) {
await reportPluginCapability({
key: pluginProtocolListProvidersEventName,
state: 'ready',
metadata: {
source: 'stage-ui',
},
})
}
// Preload local inference models (Kokoro TTS, etc.) in background after a delay
inferencePreload.triggerPreload()
await fullStageRuntime?.initialize()
})
onUnmounted(() => {
@ -273,11 +298,7 @@ watch(themeColorsHueDynamic, () => {
}, { immediate: true })
onUnmounted(() => {
if (!isChatWindowRoute()) {
contextBridgeStore.dispose()
}
mcpToolsStore.dispose()
pluginToolsStore.dispose()
fullStageRuntime?.dispose()
})
</script>
@ -285,7 +306,7 @@ onUnmounted(() => {
<ToasterRoot @close="id => toast.dismiss(id)">
<Toaster />
</ToasterRoot>
<ResizeHandler />
<ResizeHandler v-if="!isSpotlightWindowRoute" />
<RouterView />
</template>

View file

@ -1,3 +1,152 @@
<script setup lang="ts">
import type { ShortcutAccelerator, ShortcutFailureReason } from '@proj-airi/stage-shared/global-shortcut'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { formatAccelerator, ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
import { Button } from '@proj-airi/ui'
import { isMacOS } from 'std-env'
import { computed, onMounted, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
electronSpotlightShortcutGet,
electronSpotlightShortcutSet,
} from '../../../../shared/eventa'
import { isSafeSpotlightAccelerator } from '../../../../shared/spotlight-shortcut'
const getShortcut = useElectronEventaInvoke(electronSpotlightShortcutGet)
const setShortcut = useElectronEventaInvoke(electronSpotlightShortcutSet)
const { t } = useI18n()
const tt = (key: string) => t(`tamagotchi.settings.pages.system.window-shortcuts.${key}`)
const accelerator = shallowRef<ShortcutAccelerator>()
const recording = shallowRef(false)
const shortcutLabel = computed(() => {
if (recording.value)
return tt('actions.recording')
return accelerator.value
? formatAccelerator(accelerator.value).replaceAll('Key', '').replaceAll('Digit', '').replaceAll('+', ' + ')
: tt('empty')
})
function errorKeyForReason(reason: ShortcutFailureReason) {
if (reason === ShortcutFailureReasons.Conflict)
return 'errors.conflict'
if (reason === ShortcutFailureReasons.Invalid)
return 'errors.requiresModifier'
return 'errors.failed'
}
function acceleratorFromEvent(event: KeyboardEvent): ShortcutAccelerator | null {
if (event.repeat || ['Alt', 'Control', 'Meta', 'Shift'].includes(event.key))
return null
const modifiers: ShortcutAccelerator['modifiers'] = []
if (event.metaKey)
modifiers.push(isMacOS ? 'cmd' : 'super')
if (event.ctrlKey)
modifiers.push('ctrl')
if (event.altKey)
modifiers.push('alt')
if (event.shiftKey)
modifiers.push('shift')
return { modifiers, key: event.code }
}
async function saveShortcut(next: ShortcutAccelerator | null) {
try {
const result = await setShortcut({ accelerator: next })
if (!result.ok) {
toast.error(tt(errorKeyForReason(result.reason)))
return
}
accelerator.value = result.actualAccelerator ?? next ?? accelerator.value
}
catch {
toast.error(tt('errors.failed'))
}
}
function recordShortcut(event: KeyboardEvent) {
if (!recording.value)
return
event.preventDefault()
event.stopPropagation()
if (event.code === 'Escape') {
recording.value = false
return
}
const next = acceleratorFromEvent(event)
if (!next)
return
recording.value = false
if (!isSafeSpotlightAccelerator(next)) {
toast.error(tt('errors.requiresModifier'))
return
}
void saveShortcut(next)
}
onMounted(async () => {
try {
accelerator.value = await getShortcut()
}
catch {
toast.error(tt('errors.load'))
}
})
</script>
<template>
<slot />
<section
:class="['flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800']"
@keydown.capture="recordShortcut"
>
<div :class="['flex items-start gap-3']">
<div :class="['min-w-0 flex-1']">
<h2 :class="['text-sm text-neutral-900 font-medium dark:text-neutral-50']">
{{ tt('spotlight.title') }}
</h2>
<p :class="['mt-1 text-xs text-neutral-500 leading-relaxed dark:text-neutral-400']">
{{ tt('spotlight.description') }}
</p>
</div>
</div>
<div :class="['flex items-center gap-2']">
<button
type="button"
:class="[
'min-h-12 flex-1 rounded-lg border-2 border-solid px-3 py-2 text-left font-mono text-sm transition-colors',
'border-neutral-100 bg-neutral-50 text-neutral-900 hover:bg-neutral-100 active:bg-neutral-200',
'dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:hover:bg-neutral-900 dark:active:bg-neutral-800',
]"
@click="recording = true"
>
<span :class="recording ? ['animate-pulse animate-duration-2s animate-count-infinite'] : []">
{{ shortcutLabel }}
</span>
</button>
<Button
size="md"
:label="tt('actions.reset')"
@click="saveShortcut(null)"
/>
</div>
</section>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: tamagotchi.settings.pages.system.window-shortcuts.title
subtitleKey: settings.title
stageTransition:
name: slide
</route>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useWindowFocus } from '@vueuse/core'
import { shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
electronSpotlightHide,
electronSpotlightShowResultNotification,
} from '../../shared/eventa'
import { useChatSyncStore } from '../stores/chat-sync'
const messageInput = shallowRef('')
const isComposing = shallowRef(false)
const sending = shallowRef(false)
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
const chatSyncStore = useChatSyncStore()
const hideSpotlightWindow = useElectronEventaInvoke(electronSpotlightHide)
const showResultNotification = useElectronEventaInvoke(electronSpotlightShowResultNotification)
const { t } = useI18n()
watch(useWindowFocus(), (focused) => {
if (!focused) {
messageInput.value = ''
return
}
requestAnimationFrame(() => inputRef.value?.focus())
})
async function handleSend() {
if (isComposing.value || sending.value)
return
const text = messageInput.value.trim()
if (!text)
return
messageInput.value = ''
sending.value = true
try {
await hideSpotlightWindow()
const result = await chatSyncStore.requestSpotlightIngest({ text })
await showResultNotification({
body: result.visibleText.trim(),
})
}
catch (error) {
await showResultNotification({
body: t('tamagotchi.spotlight.errors.prefix', {
message: errorMessageFrom(error) ?? t('tamagotchi.spotlight.errors.unknown'),
}),
})
}
finally {
sending.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
void hideSpotlightWindow()
return
}
if (event.key !== 'Enter' || isComposing.value)
return
event.preventDefault()
void handleSend()
}
</script>
<template>
<main
:class="[
'h-full w-full',
'flex items-center justify-center',
'bg-transparent px-5 py-5',
]"
>
<div
:class="[
'spotlight-card relative overflow-hidden',
'min-h-14 w-full',
'flex items-center px-6',
'rounded-full',
'bg-white/88 dark:bg-neutral-900/88',
'backdrop-blur-3xl backdrop-saturate-150',
'shadow-lg shadow-black/20',
'ring-1 ring-black/5 dark:ring-white/10',
]"
>
<input
ref="inputRef"
v-model="messageInput"
:disabled="sending"
autofocus
type="text"
placeholder="Ask AIRI…"
:class="[
'relative z-1',
'w-full bg-transparent',
'text-lg outline-none',
'text-neutral-900 dark:text-neutral-50',
'placeholder:text-neutral-400 dark:placeholder:text-neutral-500',
]"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@keydown="handleKeydown"
>
</div>
</main>
</template>
<style scoped>
.spotlight-card::before {
pointer-events: none;
--at-apply: 'bg-gradient-to-r from-primary-500/25 via-primary-500/12 to-transparent dark:from-primary-400/25 dark:via-primary-400/12 dark:to-transparent';
content: '';
position: absolute;
inset: 0;
z-index: 0;
width: 85%;
height: 100%;
mask-image: linear-gradient(120deg, white 100%);
}
.spotlight-card::after {
pointer-events: none;
--at-apply: 'bg-dotted-[primary-300/35] dark:bg-dotted-[primary-200/16]';
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
background-size: 10px 10px;
content: '';
mask-image: linear-gradient(165deg, white 30%, transparent 55%);
}
</style>
<route lang="yaml">
meta:
layout: stage
</route>

View file

@ -42,6 +42,16 @@ describe('createChatSyncWindowLifecycle', async () => {
expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
})
it('resolves spotlight windows as followers', () => {
const lifecycle = createChatSyncWindowLifecycle('/', '#/spotlight')
lifecycle.initialize()
lifecycle.dispose()
expect(chatSyncStoreMock.initialize).toHaveBeenCalledWith('follower')
expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
})
it('does not initialize chat sync for unrelated windows', () => {
const lifecycle = createChatSyncWindowLifecycle('/', '#/widgets')

View file

@ -7,6 +7,9 @@ function normalizeRoutePath(routePath: string) {
return path || '/'
}
/**
* Resolves hash routes before Vue Router hydrates `route.path`.
*/
export function resolveInitialChatSyncRoutePath(routePath: string, hash = globalThis.location?.hash ?? '') {
const hashPath = hash.startsWith('#') ? hash.slice(1) : ''
return normalizeRoutePath(hashPath || routePath)
@ -16,7 +19,7 @@ function resolveChatSyncWindowRole(routePath: string): ChatSyncWindowRole | null
const path = normalizeRoutePath(routePath)
if (path === '/')
return 'authority'
if (path === '/chat')
if (path === '/chat' || path === '/spotlight')
return 'follower'
return null
}

View file

@ -11,9 +11,16 @@ interface MockBroadcastMessageEvent<T> {
}
type MockListener = (event: MockBroadcastMessageEvent<unknown>) => void
interface MockChatMessage {
role: string
content: string
slices?: Array<{ type: string, text?: string }>
tool_results?: unknown[]
}
class MockBroadcastChannel {
static channels = new Map<string, Set<MockBroadcastChannel>>()
static messages: unknown[] = []
static reset() {
for (const peers of MockBroadcastChannel.channels.values()) {
@ -21,6 +28,7 @@ class MockBroadcastChannel {
peer.listeners.clear()
}
MockBroadcastChannel.channels.clear()
MockBroadcastChannel.messages = []
}
readonly name: string
@ -42,6 +50,8 @@ class MockBroadcastChannel {
}
postMessage(data: unknown) {
MockBroadcastChannel.messages.push(data)
const peers = MockBroadcastChannel.channels.get(this.name)
if (!peers)
return
@ -64,9 +74,27 @@ class MockBroadcastChannel {
}
}
function postedMessagesOfType<T extends string>(type: T) {
return MockBroadcastChannel.messages.filter((message): message is { type: T } & Record<string, unknown> => {
return typeof message === 'object'
&& message !== null
&& 'type' in message
&& message.type === type
})
}
function assistantMessage(content: string): MockChatMessage {
return {
role: 'assistant',
content,
slices: [{ type: 'text', text: content }],
tool_results: [],
}
}
interface MockState {
activeSessionId: Ref<string>
sessionMessages: Ref<Record<string, Array<{ role: string, content: string }>>>
sessionMessages: Ref<Record<string, MockChatMessage[]>>
sessionMetas: Ref<Record<string, unknown>>
applyRemoteSnapshot: ReturnType<typeof vi.fn>
setSessionMessages: ReturnType<typeof vi.fn>
@ -132,28 +160,37 @@ vi.mock('./tools/builtin/weather', () => ({
weatherTools: vi.fn(async () => []),
}))
/**
* @example
* describe('useChatSyncStore authority ingest failures', () => {
* it('persists ingest errors into authoritative session snapshot', async () => {})
* })
*/
describe('useChatSyncStore authority ingest failures', async () => {
vi.mock('./tools/builtin/image-journal', () => ({
imageJournalTools: vi.fn(async () => []),
}))
describe('useChatSyncStore', async () => {
const { useChatSyncStore } = await import('./chat-sync')
function initializeAuthorityAndFollower() {
const authorityStore = useChatSyncStore()
authorityStore.initialize('authority')
setActivePinia(createPinia())
const followerStore = useChatSyncStore()
followerStore.initialize('follower')
return { authorityStore, followerStore }
}
beforeEach(() => {
setActivePinia(createPinia())
MockBroadcastChannel.reset()
vi.restoreAllMocks()
const activeSessionId = ref('session-1')
const sessionMessages = ref<Record<string, Array<{ role: string, content: string }>>>({
const sessionMessages = ref<Record<string, MockChatMessage[]>>({
'session-1': [{ role: 'system', content: 'init' }],
})
const sessionMetas = ref<Record<string, unknown>>({})
const applyRemoteSnapshot = vi.fn((snapshot: {
activeSessionId: string
sessionMessages: Record<string, Array<{ role: string, content: string }>>
sessionMessages: Record<string, MockChatMessage[]>
sessionMetas: Record<string, unknown>
}) => {
activeSessionId.value = snapshot.activeSessionId
@ -161,7 +198,7 @@ describe('useChatSyncStore authority ingest failures', async () => {
sessionMetas.value = snapshot.sessionMetas
})
const setSessionMessages = vi.fn((sessionId: string, next: Array<{ role: string, content: string }>) => {
const setSessionMessages = vi.fn((sessionId: string, next: MockChatMessage[]) => {
sessionMessages.value[sessionId] = next
})
@ -189,15 +226,8 @@ describe('useChatSyncStore authority ingest failures', async () => {
MockBroadcastChannel.reset()
})
/**
* @example
* it('keeps region-availability errors visible for follower windows', async () => {
* // authority receives ingest command failure
* // authoritative session gets role:error entry
* })
*/
it('stores command ingest errors in authority session history', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChatSyncStore()
store.initialize('authority')
@ -222,28 +252,14 @@ describe('useChatSyncStore authority ingest failures', async () => {
expect(persistedMessages).toHaveLength(2)
expect(persistedMessages[1]?.role).toBe('error')
expect(persistedMessages[1]?.content).toContain('This model is not available in your region')
expect(consoleError).toHaveBeenCalledWith('[chat-sync] command failed', expect.objectContaining({
command: 'ingest',
requestId: 'req-1',
errorMessage: expect.stringContaining('This model is not available in your region'),
payload: expect.objectContaining({
text: 'hello',
sessionId: 'session-1',
}),
}))
peer.close()
store.dispose()
})
/**
* @example
* await expect(store.requestIngest({ text: 'hello' })).rejects.toThrow(/timed out/i)
* expect(console.error).toHaveBeenCalledWith('[chat-sync] command timed out waiting for authority response', expect.any(Object))
*/
it('logs follower command timeouts with request metadata', async () => {
it('rejects follower command timeouts after thirty seconds', async () => {
vi.useFakeTimers()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChatSyncStore()
store.initialize('follower')
@ -256,28 +272,11 @@ describe('useChatSyncStore authority ingest failures', async () => {
await vi.advanceTimersByTimeAsync(30000)
await expectedRejection
expect(consoleError).toHaveBeenCalledWith('[chat-sync] command timed out waiting for authority response', expect.objectContaining({
command: 'ingest',
mode: 'follower',
requestId: expect.any(String),
errorMessage: 'Timed out waiting for chat authority response',
payload: expect.objectContaining({
text: 'hello timeout',
sessionId: 'session-1',
}),
}))
store.dispose()
vi.useRealTimers()
})
/**
* @example
* it('replaces the last failed turn before retrying', async () => {
* // authority receives retry command for trailing user -> error pair
* // authoritative session removes that failed turn before re-ingesting the user text
* })
*/
it('replaces the last failed turn before retrying', async () => {
mockState.sessionMessages.value['session-1'] = [
{ role: 'system', content: 'init' },
@ -325,12 +324,6 @@ describe('useChatSyncStore authority ingest failures', async () => {
store.dispose()
})
/**
* @example
* it('rewinds from the source user turn when retry targets an assistant message', async () => {
* // future assistant retry still trims the whole tail from its originating user turn
* })
*/
it('rewinds from the source user turn when retry targets an assistant message', async () => {
mockState.sessionMessages.value['session-1'] = [
{ role: 'system', content: 'init' },
@ -370,14 +363,6 @@ describe('useChatSyncStore authority ingest failures', async () => {
store.dispose()
})
/**
* @example
* it('keeps the follower chat window on its local session while applying remote snapshots', async () => {
* // follower already displays session-2
* // authority snapshot arrives with session-1 as active
* // follower keeps session-2 selected but still receives session-2 message updates
* })
*/
it('keeps the follower chat window on its local session while applying remote snapshots', async () => {
mockState.activeSessionId.value = 'session-2'
mockState.sessionMessages.value = {
@ -414,4 +399,66 @@ describe('useChatSyncStore authority ingest failures', async () => {
authority.close()
store.dispose()
})
it('sends spotlight commands through shared request and response messages', async () => {
mockState.ingest.mockImplementationOnce(async () => {
mockState.sessionMessages.value['session-1'] = [
...(mockState.sessionMessages.value['session-1'] ?? []),
assistantMessage('visible reply'),
]
})
const { authorityStore, followerStore } = initializeAuthorityAndFollower()
const result = await followerStore.requestSpotlightIngest({ text: 'hello spotlight' })
const spotlightCommands = postedMessagesOfType('command')
.filter(message => message.command === 'spotlight-ingest')
const responses = postedMessagesOfType('response')
expect(result).toEqual({
sessionId: 'session-1',
visibleText: 'visible reply',
})
expect(spotlightCommands).toEqual([
expect.objectContaining({
type: 'command',
command: 'spotlight-ingest',
payload: {
text: 'hello spotlight',
},
}),
])
expect(responses).toEqual([
expect.objectContaining({
type: 'response',
ok: true,
result: {
sessionId: 'session-1',
visibleText: 'visible reply',
},
}),
])
expect(mockState.ingest).toHaveBeenCalledWith('hello spotlight', expect.objectContaining({
tools: expect.any(Function),
}), 'session-1')
authorityStore.dispose()
followerStore.dispose()
})
it('uses an independent five minute timeout for spotlight requests', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChatSyncStore()
store.initialize('follower')
const pending = store.requestSpotlightIngest({ text: 'hello timeout' })
const expectedRejection = expect(pending).rejects.toThrow('Spotlight response timed out')
await vi.advanceTimersByTimeAsync(300000)
await expectedRejection
store.dispose()
vi.useRealTimers()
})
})

View file

@ -5,6 +5,7 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { extractMessageText } from '@proj-airi/stage-ui/libs/chat-sync/wire-message'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@ -46,24 +47,47 @@ interface IngestCommandPayload {
toolset?: ToolsetId
}
interface SpotlightIngestPayload {
text: string
}
interface SpotlightIngestResult {
sessionId: string
visibleText: string
}
interface ChatCommandMessage<C extends string = string, P = unknown> {
type: 'command'
authorityId?: string
requestId: string
senderId: string
command: C
payload: P
}
interface RetryCommandPayload {
sessionId?: string
index: number
}
type ChatResponsePayload
= | { ok: true, result?: SpotlightIngestResult }
| { ok: false, error?: string }
type ChatSyncMessage
= | { type: 'authority-announcement', authorityId: string, sentAt: number }
| { type: 'request-snapshot', requestId: string, senderId: string }
| { type: 'session-snapshot', authorityId: string, snapshot: SessionSnapshotPayload }
| { type: 'stream-snapshot', authorityId: string, snapshot: StreamSnapshotPayload }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'ingest', payload: IngestCommandPayload }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'retry', payload: RetryCommandPayload }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'cleanup', payload: { sessionId?: string } }
| { type: 'command', authorityId?: string, requestId: string, senderId: string, command: 'delete-message', payload: { sessionId?: string, messageId?: string, index?: number } }
| { type: 'response', requestId: string, authorityId: string, ok: boolean, error?: string }
| ChatCommandMessage<'ingest', IngestCommandPayload>
| ChatCommandMessage<'spotlight-ingest', SpotlightIngestPayload>
| ChatCommandMessage<'retry', RetryCommandPayload>
| ChatCommandMessage<'cleanup', { sessionId?: string }>
| ChatCommandMessage<'delete-message', { sessionId?: string, messageId?: string, index?: number }>
| ({ type: 'response', requestId: string, authorityId: string } & ChatResponsePayload)
interface PendingRequest {
resolve: () => void
resolve: (result?: unknown) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
@ -71,6 +95,7 @@ interface PendingRequest {
const CHAT_SYNC_CHANNEL_NAME = 'airi:stage-tamagotchi:chat-sync'
const AUTHORITY_HEARTBEAT_INTERVAL_MS = 1000
const REQUEST_TIMEOUT_MS = 30000
const SPOTLIGHT_REQUEST_TIMEOUT_MS = 5 * 60 * 1000
function createRequestId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
@ -137,19 +162,6 @@ function previewChatSyncPayload(payload: unknown): unknown {
}
}
/**
* Logs chat-sync failures at the BroadcastChannel boundary.
*
* Use when:
* - A follower window times out waiting for the authority window
* - The authority window fails while executing a forwarded chat command
*
* Expects:
* - `details` only contains structured-clone-friendly diagnostic metadata
*
* Returns:
* - Writes an error entry to the renderer console for postmortem debugging
*/
function logChatSyncError(message: string, error: unknown, details: Record<string, unknown>) {
console.error(`[chat-sync] ${message}`, {
...details,
@ -300,7 +312,15 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
return undefined
}
async function executeIngest(payload: IngestCommandPayload) {
function readNewAssistantVisibleText(sessionId: string, fromIndex: number): string {
const assistant = chatSession.getSessionMessages(sessionId)
.slice(fromIndex)
.reverse()
.find(message => message.role === 'assistant')
return assistant ? extractMessageText(assistant) : ''
}
async function executeIngest(payload: IngestCommandPayload): Promise<void> {
const providerId = activeProvider.value
const modelId = activeModel.value
if (!providerId || !modelId) {
@ -321,8 +341,30 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
}, payload.sessionId)
}
async function executeSpotlightIngest(payload: SpotlightIngestPayload): Promise<SpotlightIngestResult> {
// NOTICE: `chatOrchestrator.ingest()` returns void; remove this snapshot
// read once ingest returns `{ sessionId, visibleText }`.
const sessionId = activeSessionId.value
const previousMessageCount = chatSession.getSessionMessages(sessionId).length
await executeIngest({
text: payload.text,
toolset: 'artistry',
sessionId,
})
const visibleText = readNewAssistantVisibleText(sessionId, previousMessageCount)
if (!visibleText.trim())
throw new Error('Spotlight returned an empty response')
return {
sessionId,
visibleText,
}
}
async function executeRetry(payload: RetryCommandPayload) {
const sessionId = payload.sessionId || chatSession.activeSessionId
const sessionId = payload.sessionId || activeSessionId.value
const currentMessages = chatSession.getSessionMessages(sessionId)
const sourceIndex = resolveRetrySourceIndex(currentMessages, payload.index)
if (sourceIndex < 0)
@ -343,7 +385,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
}
function executeDeleteMessage(payload: { sessionId?: string, messageId?: string, index?: number }) {
const sessionId = payload.sessionId || chatSession.activeSessionId
const sessionId = payload.sessionId || activeSessionId.value
const nextMessages = chatSession.getSessionMessages(sessionId).filter((message, index) => {
if (payload.messageId)
return message.id !== payload.messageId
@ -356,7 +398,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
}
function appendIngestErrorMessage(payload: IngestCommandPayload, message: string) {
const sessionId = payload.sessionId || chatSession.activeSessionId
const sessionId = payload.sessionId || activeSessionId.value
const nextMessages = [
...chatSession.getSessionMessages(sessionId),
{
@ -367,17 +409,27 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
chatSession.setSessionMessages(sessionId, nextMessages)
}
function authorityCommandMeta(message: { requestId: string, senderId: string, command: string, payload: unknown }) {
return {
mode: mode.value,
authorityId: authorityId.value,
requestId: message.requestId,
senderId: message.senderId,
command: message.command,
payload: previewChatSyncPayload(message.payload),
}
}
async function handleCommand(message: Extract<ChatSyncMessage, { type: 'command' }>) {
if (mode.value !== 'authority')
return
const respond = (ok: boolean, error?: string) => {
const respond = (response: ChatResponsePayload) => {
post({
type: 'response',
requestId: message.requestId,
authorityId: instanceId,
ok,
error,
...response,
})
}
@ -386,6 +438,9 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
case 'ingest':
await executeIngest(message.payload)
break
case 'spotlight-ingest':
respond({ ok: true, result: await executeSpotlightIngest(message.payload) })
return
case 'retry':
await executeRetry(message.payload)
break
@ -397,37 +452,45 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
break
}
respond(true)
respond({ ok: true })
}
catch (error) {
const errorMessage = errorMessageFrom(error) ?? 'Unknown chat sync command failure'
logChatSyncError('command failed', error, {
mode: mode.value,
authorityId: authorityId.value,
requestId: message.requestId,
senderId: message.senderId,
command: message.command,
payload: previewChatSyncPayload(message.payload),
})
logChatSyncError('command failed', error, authorityCommandMeta(message))
if (message.command === 'ingest')
if (message.command === 'ingest') {
appendIngestErrorMessage(message.payload, errorMessage)
}
else if (message.command === 'spotlight-ingest') {
appendIngestErrorMessage({
text: message.payload.text,
toolset: 'artistry',
sessionId: activeSessionId.value,
}, errorMessage)
}
respond(false, errorMessage)
respond({ ok: false, error: errorMessage })
}
}
function handleResponse(message: Extract<ChatSyncMessage, { type: 'response' }>) {
const pending = pendingRequests.get(message.requestId)
function takePendingRequest(requestId: string): PendingRequest | undefined {
const pending = pendingRequests.get(requestId)
if (!pending)
return undefined
clearTimeout(pending.timeout)
pendingRequests.delete(requestId)
return pending
}
function settleResponse(message: Extract<ChatSyncMessage, { type: 'response' }>) {
const pending = takePendingRequest(message.requestId)
if (!pending)
return
clearTimeout(pending.timeout)
pendingRequests.delete(message.requestId)
if (message.ok) {
pending.resolve()
pending.resolve('result' in message ? message.result : undefined)
return
}
@ -465,7 +528,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
void handleCommand(message)
return
case 'response':
handleResponse(message)
settleResponse(message)
}
}
@ -513,23 +576,24 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
post({ type: 'request-snapshot', requestId: createRequestId(), senderId: instanceId })
}
function dispatchCommand(message: Extract<ChatSyncMessage, { type: 'command' }>) {
return new Promise<void>((resolve, reject) => {
function dispatch<T>(
message: Extract<ChatSyncMessage, { type: 'command' }>,
timeoutMs: number = REQUEST_TIMEOUT_MS,
timeoutError: () => Error = () => new Error('Timed out waiting for chat authority response'),
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingRequests.delete(message.requestId)
const error = new Error('Timed out waiting for chat authority response')
logChatSyncError('command timed out waiting for authority response', error, {
mode: mode.value,
authorityId: authorityId.value,
requestId: message.requestId,
senderId: message.senderId,
command: message.command,
payload: previewChatSyncPayload(message.payload),
})
const error = timeoutError()
logChatSyncError('command timed out waiting for authority response', error, authorityCommandMeta(message))
reject(error)
}, REQUEST_TIMEOUT_MS)
}, timeoutMs)
pendingRequests.set(message.requestId, { resolve, reject, timeout })
pendingRequests.set(message.requestId, {
resolve: result => resolve(result as T),
reject,
timeout,
})
post(message)
})
}
@ -540,7 +604,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
return
}
return await dispatchCommand({
return await dispatch<void>({
type: 'command',
requestId: createRequestId(),
senderId: instanceId,
@ -549,13 +613,26 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
})
}
async function requestSpotlightIngest(payload: SpotlightIngestPayload) {
if (mode.value === 'authority')
return executeSpotlightIngest(payload)
return dispatch<SpotlightIngestResult>({
type: 'command',
requestId: createRequestId(),
senderId: instanceId,
command: 'spotlight-ingest',
payload,
}, SPOTLIGHT_REQUEST_TIMEOUT_MS, () => new Error('Spotlight response timed out'))
}
async function requestRetry(payload: RetryCommandPayload) {
if (mode.value === 'authority') {
await executeRetry(payload)
return
}
return await dispatchCommand({
return await dispatch<void>({
type: 'command',
requestId: createRequestId(),
senderId: instanceId,
@ -570,7 +647,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
return
}
return await dispatchCommand({
return await dispatch<void>({
type: 'command',
requestId: createRequestId(),
senderId: instanceId,
@ -585,7 +662,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
return
}
return await dispatchCommand({
return await dispatch<void>({
type: 'command',
requestId: createRequestId(),
senderId: instanceId,
@ -609,6 +686,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
initialize,
dispose,
requestIngest,
requestSpotlightIngest,
requestRetry,
requestCleanup,
requestDeleteMessage,

View file

@ -1,6 +1,7 @@
import type { Locale } from '@intlify/core'
import type { ServerOptions } from '@proj-airi/server-runtime/server'
import type {
ShortcutAccelerator,
ShortcutBinding,
ShortcutRegistrationResult,
} from '@proj-airi/stage-shared/global-shortcut'
@ -31,6 +32,10 @@ export const electronOpenMainDevtools = defineInvokeEventa('eventa:invoke:electr
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 electronSpotlightHide = defineInvokeEventa<void>('eventa:invoke:electron:windows:spotlight:hide')
export const electronSpotlightShowResultNotification = defineInvokeEventa<void, { body: string }>('eventa:invoke:electron:windows:spotlight:show-result-notification')
export const electronSpotlightShortcutGet = defineInvokeEventa<ShortcutAccelerator>('eventa:invoke:electron:windows:spotlight:shortcut:get')
export const electronSpotlightShortcutSet = defineInvokeEventa<ShortcutRegistrationResult, { accelerator: ShortcutAccelerator | null }>('eventa:invoke:electron:windows:spotlight:shortcut:set')
export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open')
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { key: string, route?: string, width?: number, height?: number, x?: number, y?: number }>('eventa:invoke:electron:windows:devtools:open')

View file

@ -0,0 +1,7 @@
import type { ShortcutAccelerator, ShortcutModifier } from '@proj-airi/stage-shared/global-shortcut'
const safeModifiers = new Set<ShortcutModifier>(['cmd', 'ctrl', 'alt', 'super'])
export function isSafeSpotlightAccelerator(accelerator: ShortcutAccelerator): boolean {
return accelerator.modifiers.some(modifier => safeModifiers.has(modifier))
}

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: Cross-Space Visibility
description: Allow the window to be visible on all workspaces, macOS only.
spotlight:
errors:
unknown: Unknown error
prefix: 'Something went wrong: {message}'
shortcutRegistrationFailed: 'Something went wrong: failed to register Spotlight shortcut.'
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: Modify the window shortcuts.
title: Window Shortcuts
empty: Not set
spotlight:
title: Spotlight shortcut
description: Click the shortcut, then press a new shortcut. Press Esc to cancel.
actions:
recording: Press keys
reset: Reset
errors:
load: Failed to load shortcut
conflict: Shortcut already in use.
failed: Failed to update shortcut
requiresModifier: Use at least one of Cmd, Ctrl, Alt, or Super.
toggle-move:
label: Toggle Move
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: Visibilidad Entre Espacios
description: Permitir que la ventana sea visible en todos los espacios de trabajo, solo macOS.
spotlight:
errors:
unknown: Error desconocido
prefix: 'Algo salió mal: {message}'
shortcutRegistrationFailed: 'Algo salió mal: no se pudo registrar el atajo de Spotlight.'
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: Modificar los atajos de ventana.
title: Atajos de Ventana
empty: Sin configurar
spotlight:
title: Atajo de Spotlight
description: Haz clic en el atajo y pulsa un nuevo atajo. Pulsa Esc para cancelar.
actions:
recording: Pulsa las teclas
reset: Restablecer
errors:
load: No se pudo cargar el atajo
conflict: El atajo ya está en uso.
failed: No se pudo actualizar el atajo
requiresModifier: 'Usa al menos una de estas teclas: Cmd, Ctrl, Alt o Super.'
toggle-move:
label: Alternar Mover
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: Visibilité sur tous les bureaux
description: Permet à la fenêtre dêtre visible sur tous les bureaux, macOS uniquement.
spotlight:
errors:
unknown: Erreur inconnue
prefix: 'Une erreur est survenue : {message}'
shortcutRegistrationFailed: 'Une erreur est survenue : échec de lenregistrement du raccourci Spotlight.'
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: Modifier les raccourcis de la fenêtre.
title: Raccourcis de la fenêtre
empty: Non défini
spotlight:
title: Raccourci Spotlight
description: Cliquez sur le raccourci, puis appuyez sur un nouveau raccourci. Appuyez sur Échap pour annuler.
actions:
recording: Appuyez sur les touches
reset: Réinitialiser
errors:
load: Échec du chargement du raccourci
conflict: Ce raccourci est déjà utilisé.
failed: Échec de la mise à jour du raccourci
requiresModifier: Utilisez au moins Cmd, Ctrl, Alt ou Super.
toggle-move:
label: (Dés)Activer - Déplacement
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: すべてのワークスペースで表示
description: ウィンドウをすべてのワークスペースで表示可能にします。macOSのみ。
spotlight:
errors:
unknown: 不明なエラー
prefix: '問題が発生しました: {message}'
shortcutRegistrationFailed: '問題が発生しました: Spotlight ショートカットを登録できませんでした。'
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: ウィンドウ用ショートカットを変更します。
title: ウィンドウショートカット
empty: 未設定
spotlight:
title: Spotlight ショートカット
description: ショートカットをクリックしてから新しいショートカットを押します。Esc でキャンセルします。
actions:
recording: キーを押してください
reset: リセット
errors:
load: ショートカットを読み込めませんでした
conflict: このショートカットは使用中です。
failed: ショートカットの更新に失敗しました
requiresModifier: Cmd、Ctrl、Alt、Super のいずれかを少なくとも 1 つ使用してください。
toggle-move:
label: 移動の切り替え
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: 모든 작업 공간에 표시
description: 모든 작업 공간에 창을 볼 수 있도록 합니다. macOS 전용 기능입니다.
spotlight:
errors:
unknown: 알 수 없는 오류
prefix: '문제가 발생했습니다: {message}'
shortcutRegistrationFailed: '문제가 발생했습니다: Spotlight 단축키를 등록하지 못했습니다.'
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: 창 바로 가기를 수정합니다.
title: 창 바로 가기
empty: 설정되지 않음
spotlight:
title: Spotlight 단축키
description: 단축키를 클릭한 다음 새 단축키를 누르세요. Esc를 누르면 취소됩니다.
actions:
recording: 키를 누르세요
reset: 재설정
errors:
load: 단축키를 불러오지 못했습니다
conflict: 이 단축키는 이미 사용 중입니다.
failed: 단축키 업데이트 실패
requiresModifier: Cmd, Ctrl, Alt 또는 Super 중 하나 이상을 사용하세요.
toggle-move:
label: 이동 켜기/끄기
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: Межпространственная видимость
description: Позволяет окну быть видимым на всех рабочих столах, только для macOS.
spotlight:
errors:
unknown: Неизвестная ошибка
prefix: 'Что-то пошло не так: {message}'
shortcutRegistrationFailed: 'Что-то пошло не так: не удалось зарегистрировать сочетание Spotlight.'
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: Редактирование горячих клавиш для управления окном
title: Горячие клавиши управления окном
empty: Не задано
spotlight:
title: Сочетание Spotlight
description: Нажмите сочетание, затем введите новое сочетание. Нажмите Esc для отмены.
actions:
recording: Нажмите клавиши
reset: Сбросить
errors:
load: Не удалось загрузить сочетание
conflict: Это сочетание уже используется.
failed: Не удалось обновить сочетание
requiresModifier: Используйте хотя бы одну клавишу из Cmd, Ctrl, Alt или Super.
toggle-move:
label: Перемещение окна
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: Hiển thị ở mọi nơi
description: Cho phép cửa sổ hiển thị trên tất cả workspaces, chỉ áp dụng cho macOS.
spotlight:
errors:
unknown: Lỗi không xác định
prefix: 'Đã xảy ra lỗi: {message}'
shortcutRegistrationFailed: 'Đã xảy ra lỗi: không đăng ký được phím tắt Spotlight.'
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: Chỉnh sửa các phím tắt cho Windows.
title: Phím tắt Windows
empty: Chưa đặt
spotlight:
title: Phím tắt Spotlight
description: Bấm vào phím tắt, rồi nhấn phím tắt mới. Nhấn Esc để hủy.
actions:
recording: Nhấn phím
reset: Đặt lại
errors:
load: Không tải được phím tắt
conflict: Phím tắt này đã được sử dụng.
failed: Không cập nhật được phím tắt
requiresModifier: Dùng ít nhất một trong các phím Cmd, Ctrl, Alt hoặc Super.
toggle-move:
label: Bật/Tắt Di chuyển
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: 跨桌面可见性
description: 允许窗口在所有虚拟桌面中可见,仅限 macOS。
spotlight:
errors:
unknown: 未知错误
prefix: 出错啦:{message}
shortcutRegistrationFailed: 出错啦:快捷键注册失败
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: 修改窗口快捷方式
title: 窗口快捷方式
empty: 未设置
spotlight:
title: Spotlight 快捷键
description: 点击快捷键后按下新的快捷键,按 Esc 取消。
actions:
recording: 请按键
reset: 重置
errors:
load: 无法加载快捷键
conflict: 该快捷键已被占用
failed: 快捷键更新失败
requiresModifier: 请至少使用 Cmd、Ctrl、Alt 或 Super 中的一个修饰键。
toggle-move:
label: 切换移动状态
toggle-resize:

View file

@ -1,6 +1,11 @@
allow-visible-on-all-workspaces:
title: 跨桌面可見性
description: 允許視窗在所有虛擬桌面中可見,僅限 macOS。
spotlight:
errors:
unknown: 未知錯誤
prefix: 出錯啦:{message}
shortcutRegistrationFailed: 出錯啦:快捷鍵註冊失敗
pages:
modules:
mcp-server:
@ -14,6 +19,18 @@ pages:
window-shortcuts:
description: 修改視窗快捷方式
title: 視窗快捷方式
empty: 未設定
spotlight:
title: Spotlight 快捷鍵
description: 點擊快捷鍵後按下新的快捷鍵,按 Esc 取消。
actions:
recording: 請按鍵
reset: 重設
errors:
load: 無法載入快捷鍵
conflict: 此快捷鍵已被佔用
failed: 快捷鍵更新失敗
requiresModifier: 請至少使用 Cmd、Ctrl、Alt 或 Super 中的一個修飾鍵。
toggle-move:
label: 切換移動狀態
toggle-resize:

View file

@ -52,7 +52,7 @@ const {
allAudioTranscriptionProvidersMetadata,
} = storeToRefs(providersStore)
const allArtistryProvidersMetadata = computed<ProviderSourceCard[]>(() => {
const allArtistryProvidersMetadata = computed<ProviderSourceCard[]>((): ProviderSourceCard[] => {
return [
{
id: 'comfyui',

View file

@ -108,6 +108,8 @@ export const ShortcutFailureReasons = {
* presses).
*/
Unsupported: 'unsupported',
/** The requested binding is well-formed but unsafe or not accepted by policy. */
Invalid: 'invalid',
} as const
export type ShortcutFailureReason = typeof ShortcutFailureReasons[keyof typeof ShortcutFailureReasons]