refactor(stage-ui,stage-tamagotchi): have plugin-tools, mcp-tools to register directly to llm.ts

This commit is contained in:
Neko Ayaka
2026-04-22 03:34:40 +08:00
parent 7e4486c068
commit 5bbf9559a4
18 changed files with 786 additions and 128 deletions
+52 -17
View File
@@ -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()
})
</script>
@@ -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<Tool['execute']>[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()
})
})
@@ -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,
}
})
@@ -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<Tool['execute']>[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()
})
})
@@ -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,
}
})