mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-09 15:58:27 +00:00
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
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:
parent
668440a732
commit
8b98a2c138
27 changed files with 1287 additions and 337 deletions
|
|
@ -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'])),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
212
apps/stage-tamagotchi/src/main/windows/spotlight/index.ts
Normal file
212
apps/stage-tamagotchi/src/main/windows/spotlight/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
149
apps/stage-tamagotchi/src/renderer/pages/spotlight.vue
Normal file
149
apps/stage-tamagotchi/src/renderer/pages/spotlight.vue
Normal 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>
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
7
apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts
Normal file
7
apps/stage-tamagotchi/src/shared/spotlight-shortcut.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const {
|
|||
allAudioTranscriptionProvidersMetadata,
|
||||
} = storeToRefs(providersStore)
|
||||
|
||||
const allArtistryProvidersMetadata = computed<ProviderSourceCard[]>(() => {
|
||||
const allArtistryProvidersMetadata = computed<ProviderSourceCard[]>((): ProviderSourceCard[] => {
|
||||
return [
|
||||
{
|
||||
id: 'comfyui',
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue