From 5bbf9559a4b8917cd0e53b9f1a081941239a9a76 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Wed, 22 Apr 2026 03:26:10 +0800 Subject: [PATCH] refactor(stage-ui,stage-tamagotchi): have plugin-tools, mcp-tools to register directly to llm.ts --- apps/stage-tamagotchi/src/renderer/App.vue | 69 ++++++-- .../src/renderer/stores/mcp-tools.test.ts | 95 +++++++++++ .../src/renderer/stores/mcp-tools.ts | 40 +++++ .../src/renderer/stores/plugin-tools.test.ts | 84 ++++++++++ .../src/renderer/stores/plugin-tools.ts | 50 ++++++ cspell.config.yaml | 1 + .../core-agent/src/runtime/llm-service.ts | 4 +- packages/stage-ui/package.json | 2 + .../stage-ui/src/stores/llm-tools.test.ts | 128 ++++++++++++++ packages/stage-ui/src/stores/llm-tools.ts | 81 +++++++++ packages/stage-ui/src/stores/llm.test.ts | 157 +++++++++++++++++- packages/stage-ui/src/stores/llm.ts | 34 +++- .../stage-ui/src/stores/mcp-tool-bridge.ts | 42 ----- .../orchestrator/spark-command.test.ts | 18 +- .../character/orchestrator/spark-command.ts | 78 ++++----- plugins/airi-plugin-game-chess/package.json | 4 +- pnpm-lock.yaml | 26 +-- pnpm-workspace.yaml | 1 + 18 files changed, 786 insertions(+), 128 deletions(-) create mode 100644 apps/stage-tamagotchi/src/renderer/stores/mcp-tools.test.ts create mode 100644 apps/stage-tamagotchi/src/renderer/stores/mcp-tools.ts create mode 100644 apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts create mode 100644 apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts create mode 100644 packages/stage-ui/src/stores/llm-tools.test.ts create mode 100644 packages/stage-ui/src/stores/llm-tools.ts delete mode 100644 packages/stage-ui/src/stores/mcp-tool-bridge.ts diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 118992985..dfc6afefc 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -9,7 +9,6 @@ import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/charac import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store' import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug' import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models' -import { clearMcpToolBridge, setMcpToolBridge } from '@proj-airi/stage-ui/stores/mcp-tool-bridge' import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server' import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge' import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' @@ -27,12 +26,11 @@ import ResizeHandler from './components/ResizeHandler.vue' import { electronGetServerChannelConfig, - electronMcpCallTool, - electronMcpListTools, electronPluginInspect, electronPluginList, electronPluginLoad, electronPluginLoadEnabled, + electronPluginSetAutoReload, electronPluginSetEnabled, electronPluginUnload, electronPluginUpdateCapability, @@ -44,6 +42,8 @@ import { } from '../shared/eventa' import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback' import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace' +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' @@ -63,6 +63,8 @@ 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 context = useElectronEventaContext() @@ -73,33 +75,63 @@ 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 listMcpTools = useElectronEventaInvoke(electronMcpListTools) -const callMcpTool = useElectronEventaInvoke(electronMcpCallTool) const setLocale = useElectronEventaInvoke(i18nSetLocale) const isChatWindowRoute = () => route.path === '/chat' +const isWidgetsWindowRoute = () => route.path === '/widgets' + +async function refreshPluginRuntimeTools() { + try { + await pluginToolsStore.refresh() + } + catch (error) { + console.warn('[App] Failed to refresh plugin runtime tools:', error) + } +} + +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: payload => setPluginEnabled(payload), - loadEnabled: () => loadEnabledPlugins(), - load: payload => loadPlugin(payload), - unload: payload => unloadPlugin(payload), + 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: MCP tools are declared from stage-ui and executed during model streaming. -// Register runtime bridge during setup to avoid missing bridge in early tool invocations. -setMcpToolBridge({ - listTools: () => listMcpTools(), - callTool: payload => callMcpTool(payload), +// 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(language, () => { i18n.locale.value = language.value @@ -143,8 +175,10 @@ onMounted(async () => { }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err)) if (!isChatWindowRoute()) { contextBridgeStore.initialize() - characterOrchestratorStore.initialize() - await startTrackingCursorPoint() + if (!isWidgetsWindowRoute()) { + characterOrchestratorStore.initialize() + await startTrackingCursorPoint() + } } // Expose stage provider definitions to plugin host APIs. @@ -176,7 +210,8 @@ onUnmounted(() => { if (!isChatWindowRoute()) { contextBridgeStore.dispose() } - clearMcpToolBridge() + mcpToolsStore.dispose() + pluginToolsStore.dispose() }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/mcp-tools.test.ts b/apps/stage-tamagotchi/src/renderer/stores/mcp-tools.test.ts new file mode 100644 index 000000000..d8d30fd31 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/stores/mcp-tools.test.ts @@ -0,0 +1,95 @@ +import type { Tool } from '@xsai/shared-chat' + +import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const invokeMocks = vi.hoisted(() => ({ + callMcpTool: vi.fn(async () => ({ + content: [{ type: 'text', text: 'ok' }], + isError: false, + })), + listMcpTools: vi.fn(async () => [{ + serverName: 'filesystem', + name: 'filesystem::search', + toolName: 'search', + description: 'Search files.', + inputSchema: { + type: 'object', + properties: {}, + }, + }]), +})) + +vi.mock('@proj-airi/electron-vueuse', () => ({ + useElectronEventaInvoke: (event: { receiveEvent?: { id?: string } }) => { + if (event?.receiveEvent?.id === 'eventa:invoke:electron:mcp:list-tools-receive') + return invokeMocks.listMcpTools + if (event?.receiveEvent?.id === 'eventa:invoke:electron:mcp:call-tool-receive') + return invokeMocks.callMcpTool + + throw new Error(`Unexpected eventa invoke: ${JSON.stringify(event)}`) + }, +})) + +describe('useTamagotchiMcpToolsStore', async () => { + const { useTamagotchiMcpToolsStore } = await import('./mcp-tools') + + beforeEach(() => { + setActivePinia(createPinia()) + invokeMocks.listMcpTools.mockClear() + invokeMocks.callMcpTool.mockClear() + }) + + /** + * @example + * await store.refresh() + * expect(llmToolsStore.toolsByProvider.mcp).toHaveLength(2) + */ + it('loads MCP tools, proxies execution, and clears them from the shared llm-tools store', async () => { + const llmToolsStore = useLlmToolsStore() + const store = useTamagotchiMcpToolsStore() + const toolOptions = {} as Parameters[1] + + await store.refresh() + + const mcpTools = llmToolsStore.toolsByProvider.mcp + const listTools = mcpTools?.find(tool => tool.function.name === 'builtIn_mcpListTools') + const callTool = mcpTools?.find(tool => tool.function.name === 'builtIn_mcpCallTool') + + expect(mcpTools).toEqual([ + expect.objectContaining({ function: expect.objectContaining({ name: 'builtIn_mcpListTools' }) }), + expect.objectContaining({ function: expect.objectContaining({ name: 'builtIn_mcpCallTool' }) }), + ]) + + const listResult = await listTools?.execute({}, toolOptions) + const callResult = await callTool?.execute({ + name: 'filesystem::search', + arguments: JSON.stringify({ query: 'hello', limit: 10 }), + }, toolOptions) + + expect(invokeMocks.listMcpTools).toHaveBeenCalledTimes(1) + expect(invokeMocks.callMcpTool).toHaveBeenCalledWith({ + name: 'filesystem::search', + arguments: { query: 'hello', limit: 10 }, + }) + expect(listResult).toEqual([{ + serverName: 'filesystem', + name: 'filesystem::search', + toolName: 'search', + description: 'Search files.', + inputSchema: { + type: 'object', + properties: {}, + }, + }]) + expect(callResult).toEqual({ + content: [{ type: 'text', text: 'ok' }], + isError: false, + }) + + store.dispose() + + expect(llmToolsStore.toolsByProvider.mcp).toBeUndefined() + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/stores/mcp-tools.ts b/apps/stage-tamagotchi/src/renderer/stores/mcp-tools.ts new file mode 100644 index 000000000..4ee4082a7 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/stores/mcp-tools.ts @@ -0,0 +1,40 @@ +import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse' +import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools' +import { createMcpTools } from '@proj-airi/stage-ui/tools/mcp' +import { defineStore } from 'pinia' + +import { electronMcpCallTool, electronMcpListTools } from '../../shared/eventa' + +/** + * Registers Electron-backed MCP tools into the shared LLM tools store. + * + * Use when: + * - The Tamagotchi renderer needs live MCP tools during chat streaming + * + * Expects: + * - Electron Eventa handlers for MCP listing and invocation are available + * + * Returns: + * - Store actions for refreshing and disposing MCP runtime tools + */ +export const useTamagotchiMcpToolsStore = defineStore('tamagotchi-mcp-tools', () => { + const llmToolsStore = useLlmToolsStore() + const listMcpTools = useElectronEventaInvoke(electronMcpListTools) + const callMcpTool = useElectronEventaInvoke(electronMcpCallTool) + + async function refresh() { + return llmToolsStore.registerTools('mcp', Promise.all(createMcpTools({ + listTools: () => listMcpTools(), + callTool: payload => callMcpTool(payload), + }))) + } + + function dispose() { + llmToolsStore.clearTools('mcp') + } + + return { + dispose, + refresh, + } +}) diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts new file mode 100644 index 000000000..20c95240e --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts @@ -0,0 +1,84 @@ +import type { Tool } from '@xsai/shared-chat' + +import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const invokeMocks = vi.hoisted(() => ({ + invokePluginTool: vi.fn(async (payload: unknown) => payload), + listPluginXsaiTools: vi.fn(async () => [ + { + ownerPluginId: 'plugin-chess', + name: 'play_chess', + description: 'Play a chess move.', + parameters: { + type: 'object', + properties: {}, + }, + }, + ]), +})) + +vi.mock('@proj-airi/electron-vueuse', () => ({ + useElectronEventaInvoke: (event: { receiveEvent?: { id?: string } }) => { + if (event?.receiveEvent?.id === 'eventa:invoke:electron:plugins:tools:list-xsai-receive') + return invokeMocks.listPluginXsaiTools + if (event?.receiveEvent?.id === 'eventa:invoke:electron:plugins:tools:invoke-receive') + return invokeMocks.invokePluginTool + + throw new Error(`Unexpected eventa invoke: ${JSON.stringify(event)}`) + }, +})) + +describe('useTamagotchiPluginToolsStore', async () => { + const { useTamagotchiPluginToolsStore } = await import('./plugin-tools') + + beforeEach(() => { + setActivePinia(createPinia()) + invokeMocks.listPluginXsaiTools.mockClear() + invokeMocks.invokePluginTool.mockClear() + }) + + /** + * @example + * await store.refresh() + * expect(llmToolsStore.toolsByProvider['plugin-tools']).toHaveLength(1) + */ + it('loads plugin xsai tools, proxies execution, and clears them from the shared llm-tools store', async () => { + const llmToolsStore = useLlmToolsStore() + const store = useTamagotchiPluginToolsStore() + const toolOptions = {} as Parameters[1] + + await store.refresh() + + const pluginTools = llmToolsStore.toolsByProvider['plugin-tools'] + const playChessTool = pluginTools?.find(tool => tool.function.name === 'play_chess') + + expect(pluginTools).toEqual([ + expect.objectContaining({ function: expect.objectContaining({ name: 'play_chess' }) }), + ]) + + const executionResult = await playChessTool?.execute({ + move: 'e2e4', + }, toolOptions) + + expect(invokeMocks.invokePluginTool).toHaveBeenCalledWith({ + ownerPluginId: 'plugin-chess', + name: 'play_chess', + input: { + move: 'e2e4', + }, + }) + expect(executionResult).toEqual({ + ownerPluginId: 'plugin-chess', + name: 'play_chess', + input: { + move: 'e2e4', + }, + }) + + store.dispose() + + expect(llmToolsStore.toolsByProvider['plugin-tools']).toBeUndefined() + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts new file mode 100644 index 000000000..84245b344 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts @@ -0,0 +1,50 @@ +import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse' +import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools' +import { rawTool } from '@xsai/tool' +import { defineStore } from 'pinia' + +import { electronPluginInvokeTool, electronPluginListXsaiTools } from '../../shared/eventa' + +/** + * Registers Electron-backed plugin xsai tools into the shared LLM tools store. + * + * Use when: + * - The Tamagotchi renderer needs plugin-provided xsai tools during chat streaming + * + * Expects: + * - Electron Eventa handlers for listing and invoking plugin tools are available + * + * Returns: + * - Store actions for refreshing and disposing plugin runtime tools + */ +export const useTamagotchiPluginToolsStore = defineStore('tamagotchi-plugin-tools', () => { + const llmToolsStore = useLlmToolsStore() + const listPluginXsaiToolDefinitions = useElectronEventaInvoke(electronPluginListXsaiTools) + const invokePluginTool = useElectronEventaInvoke(electronPluginInvokeTool) + + async function refresh() { + return llmToolsStore.registerTools('plugin-tools', listPluginXsaiToolDefinitions().then(definitions => + definitions.map(definition => + rawTool({ + name: definition.name, + description: definition.description, + parameters: definition.parameters, + execute: async input => invokePluginTool({ + ownerPluginId: definition.ownerPluginId, + name: definition.name, + input, + }), + }), + ), + )) + } + + function dispose() { + llmToolsStore.clearTools('plugin-tools') + } + + return { + dispose, + refresh, + } +}) diff --git a/cspell.config.yaml b/cspell.config.yaml index 9aeb03f97..05dd6d990 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -112,6 +112,7 @@ words: - formkit - frontmatter - gamelet + - gamelets - Genshin - giteeai - gltf diff --git a/packages/core-agent/src/runtime/llm-service.ts b/packages/core-agent/src/runtime/llm-service.ts index 444c9ca53..a90055492 100644 --- a/packages/core-agent/src/runtime/llm-service.ts +++ b/packages/core-agent/src/runtime/llm-service.ts @@ -105,7 +105,9 @@ export async function streamFrom({ headers: options?.headers, stopWhen: stepCountAtLeast(10), tools, - captureToolErrors: true, + // NOTICE: Some OpenAI-compatible gateways reject the wire-level + // `capture_tool_errors` parameter with 400 unknown_parameter. + // Keep this unset here so the request remains provider-compatible. onEvent, }) diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index 5fab545c3..5e160eb79 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -29,11 +29,13 @@ "./libs/inference": "./src/libs/inference/index.ts", "./libs/*": "./src/libs/*.ts", "./libs": "./src/libs/index.ts", + "./tools/mcp": "./src/tools/mcp.ts", "./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts", "./stores/analytics": "./src/stores/analytics/index.ts", "./stores/analytics/posthog": "./src/stores/analytics/posthog.ts", "./stores/analytics/privacy-policy": "./src/stores/analytics/privacy-policy.ts", "./stores/character": "./src/stores/character/index.ts", + "./stores/character/orchestrator/spark-notify-agent": "./src/stores/character/orchestrator/spark-notify-agent.ts", "./stores/settings/analytics": "./src/stores/settings/analytics.ts", "./stores/settings": "./src/stores/settings/index.ts", "./stores/modules/vision": "./src/stores/modules/vision/index.ts", diff --git a/packages/stage-ui/src/stores/llm-tools.test.ts b/packages/stage-ui/src/stores/llm-tools.test.ts new file mode 100644 index 000000000..9e3f3824b --- /dev/null +++ b/packages/stage-ui/src/stores/llm-tools.test.ts @@ -0,0 +1,128 @@ +import type { Tool } from '@xsai/shared-chat' + +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useLlmToolsStore } from './llm-tools' + +describe('useLlmToolsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('registers and merges tools by provider', () => { + const store = useLlmToolsStore() + const mcpTool = { function: { name: 'builtIn_mcpListTools' } } as Tool + const pluginTool = { function: { name: 'play_chess' } } as Tool + + store.registerTools('mcp', [mcpTool]) + store.registerTools('plugin-tools', [pluginTool]) + + expect(store.toolsByProvider).toEqual({ + 'mcp': [mcpTool], + 'plugin-tools': [pluginTool], + }) + expect(store.activeTools).toEqual([mcpTool, pluginTool]) + }) + + it('replaces tools for the same provider instead of appending forever', () => { + const store = useLlmToolsStore() + const first = { function: { name: 'first' } } as Tool + const second = { function: { name: 'second' } } as Tool + + store.registerTools('plugin-tools', [first]) + store.registerTools('plugin-tools', [second]) + + expect(store.toolsByProvider).toEqual({ + 'plugin-tools': [second], + }) + expect(store.activeTools).toEqual([second]) + }) + + it('clears one provider without touching the others', () => { + const store = useLlmToolsStore() + const mcpTool = { function: { name: 'builtIn_mcpListTools' } } as Tool + const pluginTool = { function: { name: 'play_chess' } } as Tool + + store.registerTools('mcp', [mcpTool]) + store.registerTools('plugin-tools', [pluginTool]) + store.clearTools('plugin-tools') + + expect(store.toolsByProvider).toEqual({ + mcp: [mcpTool], + }) + expect(store.activeTools).toEqual([mcpTool]) + }) + + it('does not change store state when the caller mutates the registered array later', () => { + const store = useLlmToolsStore() + const first = { function: { name: 'first' } } as Tool + const second = { function: { name: 'second' } } as Tool + const tools = [first] + + store.registerTools('plugin-tools', tools) + tools.push(second) + + expect(store.toolsByProvider).toEqual({ + 'plugin-tools': [first], + }) + expect(store.activeTools).toEqual([first]) + }) + + /** + * @example + * store.registerTools('plugin-tools', Promise.resolve([pluginTool])) + * await store.awaitPendingRegistrations() + */ + it('waits for async tool registrations before exposing them as active tools', async () => { + const store = useLlmToolsStore() + const pluginTool = { function: { name: 'play_chess' } } as Tool + let resolveTools: ((tools: Tool[]) => void) | undefined + const pendingTools = new Promise((resolve) => { + resolveTools = resolve + }) + const onSettled = vi.fn() + + store.registerTools('plugin-tools', pendingTools) + const pendingWait = store.awaitPendingRegistrations().then(() => { + onSettled() + }) + + await Promise.resolve() + + expect(store.toolsByProvider['plugin-tools']).toBeUndefined() + expect(store.activeTools).toEqual([]) + expect(onSettled).not.toHaveBeenCalled() + + resolveTools?.([pluginTool]) + await pendingWait + + expect(onSettled).toHaveBeenCalledTimes(1) + expect(store.toolsByProvider['plugin-tools']).toEqual([pluginTool]) + expect(store.activeTools).toEqual([pluginTool]) + }) + + /** + * @example + * store.registerTools('plugin-tools', slowTools) + * store.registerTools('plugin-tools', [latestTool]) + */ + it('ignores stale async registrations after newer tools replace the same provider', async () => { + const store = useLlmToolsStore() + const staleTool = { function: { name: 'stale' } } as Tool + const latestTool = { function: { name: 'latest' } } as Tool + let resolveTools: ((tools: Tool[]) => void) | undefined + const pendingTools = new Promise((resolve) => { + resolveTools = resolve + }) + + store.registerTools('plugin-tools', pendingTools) + store.registerTools('plugin-tools', [latestTool]) + resolveTools?.([staleTool]) + + await store.awaitPendingRegistrations() + + expect(store.toolsByProvider['plugin-tools']).toEqual([latestTool]) + expect(store.activeTools).toEqual([latestTool]) + }) +}) diff --git a/packages/stage-ui/src/stores/llm-tools.ts b/packages/stage-ui/src/stores/llm-tools.ts new file mode 100644 index 000000000..458ef93d3 --- /dev/null +++ b/packages/stage-ui/src/stores/llm-tools.ts @@ -0,0 +1,81 @@ +import type { Tool } from '@xsai/shared-chat' + +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +type ToolRegistration = Promise | Tool[] + +/** + * Stores runtime-registered xsai tools keyed by provider. + * + * Use when: + * - App runtimes need to publish additional LLM tools into shared stage-ui logic + * + * Expects: + * - Provider names are stable identifiers such as `mcp` or `plugin-tools` + * + * Returns: + * - A merged reactive list of all currently registered tools + */ +export const useLlmToolsStore = defineStore('llm-tools', () => { + const toolsByProvider = ref>({}) + const providerRegistrationTokens = new Map() + const pendingRegistrations = new Map>() + + function assignTools(provider: string, tools: Tool[]) { + toolsByProvider.value = { + ...toolsByProvider.value, + [provider]: [...tools], + } + } + + function registerTools(provider: string, tools: ToolRegistration) { + const registrationToken = Symbol(provider) + providerRegistrationTokens.set(provider, registrationToken) + + if (Array.isArray(tools)) { + pendingRegistrations.delete(provider) + assignTools(provider, tools) + return Promise.resolve([...tools]) + } + + const registration = Promise.resolve(tools) + .then((resolvedTools) => { + if (providerRegistrationTokens.get(provider) !== registrationToken) + return resolvedTools + + assignTools(provider, resolvedTools) + return resolvedTools + }) + .finally(() => { + if (providerRegistrationTokens.get(provider) === registrationToken) + pendingRegistrations.delete(provider) + }) + + pendingRegistrations.set(provider, registration.then(() => undefined, () => undefined)) + return registration + } + + function clearTools(provider: string) { + providerRegistrationTokens.set(provider, Symbol(provider)) + pendingRegistrations.delete(provider) + const { [provider]: _removed, ...remaining } = toolsByProvider.value + toolsByProvider.value = remaining + } + + async function awaitPendingRegistrations() { + while (pendingRegistrations.size > 0) + await Promise.all(pendingRegistrations.values()) + } + + const activeTools = computed(() => Object.values(toolsByProvider.value).flat()) + + // TODO: Track provider support/loading/error state if runtime diagnostics need it later. + return { + activeTools, + awaitPendingRegistrations, + clearTools, + registerTools, + toolsByProvider, + } +}) diff --git a/packages/stage-ui/src/stores/llm.test.ts b/packages/stage-ui/src/stores/llm.test.ts index f23b51c4d..b9255a602 100644 --- a/packages/stage-ui/src/stores/llm.test.ts +++ b/packages/stage-ui/src/stores/llm.test.ts @@ -1,10 +1,11 @@ import type { ChatProvider } from '@xsai-ext/providers/utils' -import type { Message } from '@xsai/shared-chat' +import type { Message, Tool } from '@xsai/shared-chat' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { isToolRelatedError, useLLM } from './llm' +import { useLlmToolsStore } from './llm-tools' const { streamTextMock, @@ -13,9 +14,9 @@ const { createSparkCommandToolMock, } = vi.hoisted(() => ({ streamTextMock: vi.fn(), - mcpMock: vi.fn(async () => []), - debugMock: vi.fn(async () => []), - createSparkCommandToolMock: vi.fn(async () => ({ + mcpMock: vi.fn(async (): Promise => []), + debugMock: vi.fn(async (): Promise => []), + createSparkCommandToolMock: vi.fn(async (): Promise => ({ name: 'spark', description: '', parameters: {}, @@ -56,6 +57,20 @@ function createMockStreamResult() { } } +function toolNameFrom(tool: unknown) { + if (typeof tool !== 'object' || tool === null) + return undefined + + const candidate = tool as { + name?: string + function?: { + name?: string + } + } + + return candidate.function?.name ?? candidate.name +} + describe('isToolRelatedError', () => { beforeEach(() => { streamTextMock.mockReset() @@ -158,7 +173,18 @@ describe('isToolRelatedError', () => { it('keeps builtin tools and auto-disables tools after tool-related errors', async () => { const store = useLLM() + const llmToolsStore = useLlmToolsStore() const customTool = { name: 'custom-tool' } as any + const runtimeTool = { + function: { + name: 'runtime_play_chess_match', + description: 'Start a runtime chess match.', + parameters: { type: 'object', properties: {} }, + }, + execute: vi.fn(), + } + + llmToolsStore.registerTools('plugin-tools', [runtimeTool as any]) streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise, tools?: unknown[] }) => { queueMicrotask(async () => { @@ -176,6 +202,7 @@ describe('isToolRelatedError', () => { expect(mcpMock).toHaveBeenCalledTimes(1) expect(debugMock).toHaveBeenCalledTimes(1) expect(firstCallTools).toContain(customTool) + expect(firstCallTools?.map(toolNameFrom)).toContain('runtime_play_chess_match') streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise, tools?: unknown[] }) => { queueMicrotask(async () => { @@ -191,4 +218,126 @@ describe('isToolRelatedError', () => { const secondCallTools = streamTextMock.mock.calls[1]?.[0]?.tools expect(secondCallTools).toBeUndefined() }) + + it('merges runtime-registered tools from the llm-tools store into the builtin tool resolver', async () => { + const store = useLLM() + const llmToolsStore = useLlmToolsStore() + const playChessTool = { + function: { + name: 'runtime_open_chess_board', + description: 'Open the runtime chess board.', + parameters: { type: 'object', properties: {} }, + }, + execute: vi.fn(), + } + const runtimeMcpStatusTool = { + function: { + name: 'runtime_sync_mcp_status', + description: 'Sync runtime MCP status.', + parameters: { type: 'object', properties: {} }, + }, + execute: vi.fn(), + } + + llmToolsStore.registerTools('mcp', [runtimeMcpStatusTool as any]) + llmToolsStore.registerTools('plugin-tools', [playChessTool as any]) + + streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise, tools?: unknown[] }) => { + queueMicrotask(async () => { + await options.onEvent({ type: 'finish', finishReason: 'stop' }) + }) + return createMockStreamResult() + }) + + await store.stream('model-a', provider, [{ role: 'user', content: 'play chess' }] as Message[]) + + const mergedTools = streamTextMock.mock.calls[0]?.[0]?.tools + expect(mergedTools).toEqual(expect.arrayContaining([runtimeMcpStatusTool, playChessTool])) + }) + + it('prefers runtime-registered tools when duplicate tool names collide with builtin tools', async () => { + const store = useLLM() + const llmToolsStore = useLlmToolsStore() + const builtinTool = { + function: { + name: 'duplicate_runtime_tool', + description: 'Builtin version.', + parameters: { type: 'object', properties: {} }, + }, + execute: vi.fn(), + } as unknown as Tool + const runtimeTool = { + function: { + name: 'duplicate_runtime_tool', + description: 'Runtime version.', + parameters: { type: 'object', properties: {} }, + }, + execute: vi.fn(), + } + + mcpMock.mockResolvedValueOnce([builtinTool] as Tool[]) + llmToolsStore.registerTools('plugin-tools', [runtimeTool as any]) + + streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise, tools?: unknown[] }) => { + queueMicrotask(async () => { + await options.onEvent({ type: 'finish', finishReason: 'stop' }) + }) + return createMockStreamResult() + }) + + await store.stream('model-a', provider, [{ role: 'user', content: 'play chess' }] as Message[]) + + const mergedTools = streamTextMock.mock.calls[0]?.[0]?.tools as Array<{ function?: { name?: string } }> + const duplicateNameTools = mergedTools.filter(tool => tool.function?.name === 'duplicate_runtime_tool') + + expect(duplicateNameTools).toHaveLength(1) + expect(duplicateNameTools[0]).toMatchObject({ + function: { + name: 'duplicate_runtime_tool', + description: 'Runtime version.', + }, + }) + }) + + /** + * @example + * llmToolsStore.registerTools('plugin-tools', pendingRuntimeTools) + * await store.stream('model-a', provider, messages) + */ + it('waits for pending runtime tool registrations before building stream tools', async () => { + const store = useLLM() + const llmToolsStore = useLlmToolsStore() + const runtimeTool = { + function: { + name: 'runtime_pending_tool', + description: 'Pending runtime tool.', + parameters: { type: 'object', properties: {} }, + }, + execute: vi.fn(), + } + let resolveTools: ((tools: unknown[]) => void) | undefined + const pendingTools = new Promise((resolve) => { + resolveTools = resolve + }) + + llmToolsStore.registerTools('plugin-tools', pendingTools as Promise) + + streamTextMock.mockImplementationOnce((options: { onEvent: (event: unknown) => Promise, tools?: unknown[] }) => { + queueMicrotask(async () => { + await options.onEvent({ type: 'finish', finishReason: 'stop' }) + }) + return createMockStreamResult() + }) + + const pendingStream = store.stream('model-a', provider, [{ role: 'user', content: 'play chess' }] as Message[]) + await Promise.resolve() + + expect(streamTextMock).not.toHaveBeenCalled() + + resolveTools?.([runtimeTool]) + await pendingStream + + const mergedTools = streamTextMock.mock.calls[0]?.[0]?.tools + expect(mergedTools?.map(toolNameFrom)).toContain('runtime_pending_tool') + }) }) diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index ba157acf5..f59bcff17 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -1,22 +1,36 @@ import type { StreamOptions } from '@proj-airi/core-agent' import type { WebSocketEvents } from '@proj-airi/server-sdk' import type { ChatProvider } from '@xsai-ext/providers/utils' -import type { Message } from '@xsai/shared-chat' +import type { Message, Tool } from '@xsai/shared-chat' import { streamFrom as coreStreamFrom, isToolRelatedError, modelKey } from '@proj-airi/core-agent' import { listModels } from '@xsai/model' +import { uniqBy } from 'es-toolkit' import { defineStore } from 'pinia' import { ref } from 'vue' import { createSparkCommandTool, debug, mcp } from '../tools' +import { useLlmToolsStore } from './llm-tools' import { useModsServerChannelStore } from './mods/api/channel-server' export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent' export { isToolRelatedError } from '@proj-airi/core-agent' +function toolNameFrom(tool: Tool) { + const candidate = tool as Tool & { + name?: string + function?: { + name?: string + } + } + + return candidate.function?.name ?? candidate.name +} + export const useLLM = defineStore('llm', () => { const toolsCompatibility = ref>(new Map()) const modsServerChannelStore = useModsServerChannelStore() + const llmToolsStore = useLlmToolsStore() async function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) { const key = modelKey(model, chatProvider) @@ -42,11 +56,19 @@ export const useLLM = defineStore('llm', () => { chatProvider, messages, options: { ...options, toolsCompatibility: toolsCompatibility.value }, - builtinToolsResolver: async () => [ - ...await mcp(), - ...await debug(), - await createSparkCommandTool({ sendSparkCommand }), - ], + builtinToolsResolver: async () => { + await llmToolsStore.awaitPendingRegistrations() + + // Reverse twice so later runtime registrations win while original tool order stays stable. + return uniqBy( + [ + ...await mcp(), + ...await debug(), + ...await createSparkCommandTool({ sendSparkCommand }), + ].toReversed(), + tool => toolNameFrom(tool) ?? tool, + ).toReversed() + }, }) } catch (err) { diff --git a/packages/stage-ui/src/stores/mcp-tool-bridge.ts b/packages/stage-ui/src/stores/mcp-tool-bridge.ts deleted file mode 100644 index 6dc87a930..000000000 --- a/packages/stage-ui/src/stores/mcp-tool-bridge.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface McpToolDescriptor { - serverName: string - name: string - toolName: string - description?: string - inputSchema: Record -} - -export interface McpCallToolPayload { - name: string - arguments?: Record -} - -export interface McpCallToolResult { - content?: Array> - structuredContent?: Record - toolResult?: unknown - isError?: boolean -} - -interface McpToolBridge { - listTools: () => Promise - callTool: (payload: McpCallToolPayload) => Promise -} - -let bridge: McpToolBridge | undefined - -export function setMcpToolBridge(nextBridge: McpToolBridge) { - bridge = nextBridge -} - -export function clearMcpToolBridge() { - bridge = undefined -} - -export function getMcpToolBridge(): McpToolBridge { - if (!bridge) { - throw new Error('MCP tool bridge is not available in this runtime.') - } - - return bridge -} diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts index 80a90ed26..5c2200554 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts @@ -88,20 +88,20 @@ describe('tools/character/orchestrator/spark-command', () => { }) it('emits a strict parameter schema', async () => { - const tool = await createSparkCommandTool({ + const tools = await createSparkCommandTool({ sendSparkCommand: () => undefined, }) - expect(tool.function.name).toBe('builtIn_emitSparkCommand') - expect(tool.function.parameters.additionalProperties).toBe(false) + expect(tools[0].function.name).toBe('builtIn_emitSparkCommand') + expect(tools[0].function.parameters.additionalProperties).toBe(false) }) it('avoids propertyNames in provider-facing schema', async () => { - const tool = await createSparkCommandTool({ + const tools = await createSparkCommandTool({ sendSparkCommand: () => undefined, }) - const schema = tool.function.parameters as JsonSchema + const schema = tools[0].function.parameters as JsonSchema const guidance = getObjectSchema(schema.properties?.guidance as JsonSchema) const guidancePersona = guidance?.properties?.persona as JsonSchema const contexts = getArraySchema(schema.properties?.contexts as JsonSchema) @@ -113,11 +113,11 @@ describe('tools/character/orchestrator/spark-command', () => { }) it('uses explicit required keys for nested strict option objects', async () => { - const tool = await createSparkCommandTool({ + const tools = await createSparkCommandTool({ sendSparkCommand: () => undefined, }) - const schema = tool.function.parameters as JsonSchema + const schema = tools[0].function.parameters as JsonSchema expect(schema.required).toEqual([ 'destinations', 'interrupt', @@ -170,11 +170,11 @@ describe('tools/character/orchestrator/spark-command', () => { it('builds and dispatches spark commands with generated ids', async () => { const sendSparkCommand = vi.fn() - const tool = await createSparkCommandTool({ + const tools = await createSparkCommandTool({ sendSparkCommand, }) - const result = await tool.execute({ + const result = await tools[0].execute({ destinations: ['minecraft'], interrupt: 'soft', priority: 'high', diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts index 6d2662d60..45e0fca3d 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts @@ -26,45 +26,47 @@ export async function createSparkCommandTool(options: CreateSparkCommandToolOpti // like Azure that reject some `anyOf` nullable forms and strict-object optional-field shapes. const parameters = normalizeNullableAnyOf(await toJsonSchema(sparkCommandToolSchema) as any) - return rawTool({ - name: 'builtIn_emitSparkCommand', - description: 'Send a spark:command to one or more frontend-connected modules or sub-agents.', - parameters, - execute: async (rawPayload) => { - const payload = rawPayload as z.infer - const command = { - id: nanoid(), - eventId: nanoid(), - parentEventId: payload.parentEventId ?? undefined, - commandId: nanoid(), - interrupt: payload.interrupt ?? false, - priority: payload.priority ?? 'normal', - intent: payload.intent ?? 'action', - ack: payload.ack ?? undefined, - guidance: payload.guidance - ? { - type: payload.guidance.type, - persona: normalizeSparkCommandPersona(payload.guidance.persona ?? undefined), - options: normalizeSparkCommandGuidanceOptions(payload.guidance.options), - } - : undefined, - contexts: payload.contexts?.map(context => ({ + return [ + rawTool({ + name: 'builtIn_emitSparkCommand', + description: 'Send a spark:command to one or more frontend-connected modules or sub-agents.', + parameters, + execute: async (rawPayload) => { + const payload = rawPayload as z.infer + const command = { id: nanoid(), - contextId: nanoid(), - lane: normalizeSparkCommandStringValue(context.lane), - ideas: normalizeSparkCommandStringList(context.ideas), - hints: normalizeSparkCommandStringList(context.hints), - strategy: context.strategy, - text: context.text, - destinations: normalizeSparkCommandDestinations(context.destinations), - metadata: normalizeSparkCommandMetadata(context.metadata ?? undefined), - })), - destinations: payload.destinations, - } satisfies WebSocketEvents['spark:command'] + eventId: nanoid(), + parentEventId: payload.parentEventId ?? undefined, + commandId: nanoid(), + interrupt: payload.interrupt ?? false, + priority: payload.priority ?? 'normal', + intent: payload.intent ?? 'action', + ack: payload.ack ?? undefined, + guidance: payload.guidance + ? { + type: payload.guidance.type, + persona: normalizeSparkCommandPersona(payload.guidance.persona ?? undefined), + options: normalizeSparkCommandGuidanceOptions(payload.guidance.options), + } + : undefined, + contexts: payload.contexts?.map(context => ({ + id: nanoid(), + contextId: nanoid(), + lane: normalizeSparkCommandStringValue(context.lane), + ideas: normalizeSparkCommandStringList(context.ideas), + hints: normalizeSparkCommandStringList(context.hints), + strategy: context.strategy, + text: context.text, + destinations: normalizeSparkCommandDestinations(context.destinations), + metadata: normalizeSparkCommandMetadata(context.metadata ?? undefined), + })), + destinations: payload.destinations, + } satisfies WebSocketEvents['spark:command'] - options.sendSparkCommand(command) + options.sendSparkCommand(command) - return `spark:command sent (${command.commandId}) to ${command.destinations.join(', ')}` - }, - }) + return `spark:command sent (${command.commandId}) to ${command.destinations.join(', ')}` + }, + }), + ] } diff --git a/plugins/airi-plugin-game-chess/package.json b/plugins/airi-plugin-game-chess/package.json index 984526626..c740db298 100644 --- a/plugins/airi-plugin-game-chess/package.json +++ b/plugins/airi-plugin-game-chess/package.json @@ -17,6 +17,7 @@ "chess.js": "catalog:", "reka-ui": "catalog:", "stockfish": "catalog:", + "valibot": "catalog:", "vue": "catalog:" }, "devDependencies": { @@ -32,6 +33,7 @@ "@xsai/stream-text": "catalog:", "pinia": "catalog:", "unocss": "^66.6.7", - "vieval": "catalog:" + "vieval": "catalog:", + "xsschema": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73e2ee88c..2d82b68a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1011,7 +1011,7 @@ importers: version: 1.2.45 '@intlify/unplugin-vue-i18n': specifier: ^11.0.7 - version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@2.80.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) '@proj-airi/cap-vite': specifier: workspace:* version: link:../../packages/cap-vite @@ -1077,16 +1077,16 @@ importers: version: 4.6.4 unplugin-info: specifier: ^1.3.2 - version: 1.3.2(esbuild@0.27.2)(rollup@2.80.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) unplugin-yaml: specifier: ^4.1.0 - version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@2.80.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite: specifier: 'catalog:' version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-bundle-visualizer: specifier: ^1.2.1 - version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@2.80.0) + version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1) vite-plugin-mkcert: specifier: 'catalog:' version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -1101,7 +1101,7 @@ importers: version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) vue-macros: specifier: ^3.1.2 - version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@2.80.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) + version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3) @@ -1889,7 +1889,7 @@ importers: version: 1.2.45 '@intlify/unplugin-vue-i18n': specifier: ^11.0.7 - version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@2.80.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) '@proj-airi/iconify-meteocons': specifier: 'catalog:' version: 0.1.5 @@ -1952,16 +1952,16 @@ importers: version: 4.6.4 unplugin-info: specifier: ^1.3.2 - version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.3.2(esbuild@0.27.2)(rollup@2.80.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) unplugin-yaml: specifier: ^4.1.0 - version: 4.1.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@2.80.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite: specifier: 'catalog:' version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-bundle-visualizer: specifier: ^1.2.1 - version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1) + version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@2.80.0) vite-plugin-mkcert: specifier: 'catalog:' version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -1976,7 +1976,7 @@ importers: version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) vue-macros: specifier: ^3.1.2 - version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) + version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@2.80.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3) @@ -3861,6 +3861,9 @@ importers: stockfish: specifier: 'catalog:' version: 18.0.7 + valibot: + specifier: 'catalog:' + version: 1.2.0(typescript@5.9.3) vue: specifier: 'catalog:' version: 3.5.32(typescript@5.9.3) @@ -3904,6 +3907,9 @@ importers: vieval: specifier: 'catalog:' version: 0.0.1(@types/node@25.6.0)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + xsschema: + specifier: 'catalog:' + version: 0.5.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.3.1(typescript@5.9.3)))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6) plugins/airi-plugin-homeassistant: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b64c5a52d..c9a893c78 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -153,6 +153,7 @@ catalogs: unspeech: ^0.1.13 ignoredBuiltDependencies: + - '@ax-llm/ax' - '@prisma/client' - better-sqlite3 - simple-git-hooks # [workaround] postinstall script bundled in simple-git-hooks fails to execute with `enableGlobalVirtualStore: true`. Using local postinstall script to run `npx simple-git-hooks` instead.