diff --git a/apps/stage-tamagotchi/src/main/configs/global.ts b/apps/stage-tamagotchi/src/main/configs/global.ts index 88477d414..eb54ecdaf 100644 --- a/apps/stage-tamagotchi/src/main/configs/global.ts +++ b/apps/stage-tamagotchi/src/main/configs/global.ts @@ -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'])), }) diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 5db11b716..3ee4e0ffb 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -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({ diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts index e4329f4f8..4438ca36e 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts @@ -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() diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts index cbc86268f..c54f17c53 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts @@ -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 { + 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 (entry.driver !== 'electron') + if (!includeMainOwned && entry.owner === 'main') continue - try { - globalShortcut.unregister(entry.electronAccelerator) - } - catch (error) { - log.withError(error).warn(`Failed to unregister accelerator for "${id}"`) - } + 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 } } diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 683059328..2b877433f 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -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)) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 40e5be354..f125c81dc 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -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) diff --git a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts new file mode 100644 index 000000000..a6df01ee5 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts @@ -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 + getShortcutAccelerator: () => ShortcutAccelerator + updateShortcutAccelerator: (accelerator: ShortcutAccelerator | null) => ReturnType +} + +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 + globalShortcut: GlobalShortcutService + appConfig: Config +}): 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() + + 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, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index b5bd7a054..08365e8a0 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -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() }) @@ -285,7 +306,7 @@ onUnmounted(() => { - + diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/system/window-shortcuts.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/system/window-shortcuts.vue index ba4672f56..55a8f8ba5 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/system/window-shortcuts.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/system/window-shortcuts.vue @@ -1,3 +1,152 @@ + + + + +meta: + layout: settings + titleKey: tamagotchi.settings.pages.system.window-shortcuts.title + subtitleKey: settings.title + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/renderer/pages/spotlight.vue b/apps/stage-tamagotchi/src/renderer/pages/spotlight.vue new file mode 100644 index 000000000..00210d51c --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/spotlight.vue @@ -0,0 +1,149 @@ + + + + + + + +meta: + layout: stage + diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts index 287d6e5b3..32f9bf5f5 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts @@ -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') diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts index 4c1f8307d..ae66949b4 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts @@ -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 } diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts index 111ea4943..d97f5a1b3 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts @@ -11,9 +11,16 @@ interface MockBroadcastMessageEvent { } type MockListener = (event: MockBroadcastMessageEvent) => void +interface MockChatMessage { + role: string + content: string + slices?: Array<{ type: string, text?: string }> + tool_results?: unknown[] +} class MockBroadcastChannel { static channels = new Map>() + 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(type: T) { + return MockBroadcastChannel.messages.filter((message): message is { type: T } & Record => { + 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 - sessionMessages: Ref>> + sessionMessages: Ref> sessionMetas: Ref> applyRemoteSnapshot: ReturnType setSessionMessages: ReturnType @@ -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>>({ + const sessionMessages = ref>({ 'session-1': [{ role: 'system', content: 'init' }], }) const sessionMetas = ref>({}) const applyRemoteSnapshot = vi.fn((snapshot: { activeSessionId: string - sessionMessages: Record> + sessionMessages: Record sessionMetas: Record }) => { 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() + }) }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts index 3750d97ac..ddc0720d0 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts @@ -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 { + 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 } @@ -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) { 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 { 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 { + // 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) { 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) { - 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) { + 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) { - return new Promise((resolve, reject) => { + function dispatch( + message: Extract, + timeoutMs: number = REQUEST_TIMEOUT_MS, + timeoutError: () => Error = () => new Error('Timed out waiting for chat authority response'), + ): Promise { + return new Promise((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({ 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({ + 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({ 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({ 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({ 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, diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 1a0c7edc6..79080eece 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -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('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('eventa:invoke:electron:windows:spotlight:hide') +export const electronSpotlightShowResultNotification = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:show-result-notification') +export const electronSpotlightShortcutGet = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:shortcut:get') +export const electronSpotlightShortcutSet = defineInvokeEventa('eventa:invoke:electron:windows:spotlight:shortcut:set') export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open') export const electronOpenDevtoolsWindow = defineInvokeEventa('eventa:invoke:electron:windows:devtools:open') diff --git a/apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts b/apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts new file mode 100644 index 000000000..ec4a8f597 --- /dev/null +++ b/apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts @@ -0,0 +1,7 @@ +import type { ShortcutAccelerator, ShortcutModifier } from '@proj-airi/stage-shared/global-shortcut' + +const safeModifiers = new Set(['cmd', 'ctrl', 'alt', 'super']) + +export function isSafeSpotlightAccelerator(accelerator: ShortcutAccelerator): boolean { + return accelerator.modifiers.some(modifier => safeModifiers.has(modifier)) +} diff --git a/packages/i18n/src/locales/en/tamagotchi/settings.yaml b/packages/i18n/src/locales/en/tamagotchi/settings.yaml index 311fbe026..a2986885f 100644 --- a/packages/i18n/src/locales/en/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/es/tamagotchi/settings.yaml b/packages/i18n/src/locales/es/tamagotchi/settings.yaml index de5a194df..1409a7f42 100644 --- a/packages/i18n/src/locales/es/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/es/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/fr/tamagotchi/settings.yaml b/packages/i18n/src/locales/fr/tamagotchi/settings.yaml index 11d85bbca..99865897e 100644 --- a/packages/i18n/src/locales/fr/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/fr/tamagotchi/settings.yaml @@ -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 l’enregistrement 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: diff --git a/packages/i18n/src/locales/ja/tamagotchi/settings.yaml b/packages/i18n/src/locales/ja/tamagotchi/settings.yaml index 6f2b99529..1b875ee54 100644 --- a/packages/i18n/src/locales/ja/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/ja/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/ko/tamagotchi/settings.yaml b/packages/i18n/src/locales/ko/tamagotchi/settings.yaml index 48df67221..88e91519c 100644 --- a/packages/i18n/src/locales/ko/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/ko/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/ru/tamagotchi/settings.yaml b/packages/i18n/src/locales/ru/tamagotchi/settings.yaml index d76997906..0cfc58512 100644 --- a/packages/i18n/src/locales/ru/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/ru/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/vi/tamagotchi/settings.yaml b/packages/i18n/src/locales/vi/tamagotchi/settings.yaml index 26ee69d51..aa1a6ed06 100644 --- a/packages/i18n/src/locales/vi/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/vi/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml index 99f7eb7bb..1718970d7 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/zh-Hant/tamagotchi/settings.yaml b/packages/i18n/src/locales/zh-Hant/tamagotchi/settings.yaml index 7cdeca068..a3391be95 100644 --- a/packages/i18n/src/locales/zh-Hant/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/zh-Hant/tamagotchi/settings.yaml @@ -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: diff --git a/packages/stage-pages/src/pages/settings/providers/index.vue b/packages/stage-pages/src/pages/settings/providers/index.vue index 162290737..ee8cbe767 100644 --- a/packages/stage-pages/src/pages/settings/providers/index.vue +++ b/packages/stage-pages/src/pages/settings/providers/index.vue @@ -52,7 +52,7 @@ const { allAudioTranscriptionProvidersMetadata, } = storeToRefs(providersStore) -const allArtistryProvidersMetadata = computed(() => { +const allArtistryProvidersMetadata = computed((): ProviderSourceCard[] => { return [ { id: 'comfyui', diff --git a/packages/stage-shared/src/global-shortcut/types.ts b/packages/stage-shared/src/global-shortcut/types.ts index 466942c78..cded90496 100644 --- a/packages/stage-shared/src/global-shortcut/types.ts +++ b/packages/stage-shared/src/global-shortcut/types.ts @@ -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]