Skip to content

Commit 5bbf955

Browse files
committed
refactor(stage-ui,stage-tamagotchi): have plugin-tools, mcp-tools to register directly to llm.ts
1 parent 7e4486c commit 5bbf955

18 files changed

Lines changed: 786 additions & 128 deletions

File tree

apps/stage-tamagotchi/src/renderer/App.vue

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/charac
99
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
1010
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
1111
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
12-
import { clearMcpToolBridge, setMcpToolBridge } from '@proj-airi/stage-ui/stores/mcp-tool-bridge'
1312
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
1413
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
1514
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
@@ -27,12 +26,11 @@ import ResizeHandler from './components/ResizeHandler.vue'
2726
2827
import {
2928
electronGetServerChannelConfig,
30-
electronMcpCallTool,
31-
electronMcpListTools,
3229
electronPluginInspect,
3330
electronPluginList,
3431
electronPluginLoad,
3532
electronPluginLoadEnabled,
33+
electronPluginSetAutoReload,
3634
electronPluginSetEnabled,
3735
electronPluginUnload,
3836
electronPluginUpdateCapability,
@@ -44,6 +42,8 @@ import {
4442
} from '../shared/eventa'
4543
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
4644
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
45+
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
46+
import { useTamagotchiPluginToolsStore } from './stores/plugin-tools'
4747
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
4848
import { useStageWindowLifecycleStore } from './stores/stage-window-lifecycle'
4949
@@ -63,6 +63,8 @@ const characterOrchestratorStore = useCharacterOrchestratorStore()
6363
const analyticsStore = useSharedAnalyticsStore()
6464
const inferencePreload = useInferencePreload()
6565
const pluginHostInspectorStore = usePluginHostInspectorStore()
66+
const mcpToolsStore = useTamagotchiMcpToolsStore()
67+
const pluginToolsStore = useTamagotchiPluginToolsStore()
6668
const stageWindowLifecycleStore = useStageWindowLifecycleStore()
6769
const settingsAudioDeviceStore = useSettingsAudioDevice()
6870
const context = useElectronEventaContext()
@@ -73,33 +75,63 @@ void stageWindowLifecycleStore.initializeWindowLifecycleBridge()
7375
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
7476
const listPlugins = useElectronEventaInvoke(electronPluginList)
7577
const setPluginEnabled = useElectronEventaInvoke(electronPluginSetEnabled)
78+
const setPluginAutoReload = useElectronEventaInvoke(electronPluginSetAutoReload)
7679
const loadEnabledPlugins = useElectronEventaInvoke(electronPluginLoadEnabled)
7780
const loadPlugin = useElectronEventaInvoke(electronPluginLoad)
7881
const unloadPlugin = useElectronEventaInvoke(electronPluginUnload)
7982
const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
8083
const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition)
8184
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
82-
const listMcpTools = useElectronEventaInvoke(electronMcpListTools)
83-
const callMcpTool = useElectronEventaInvoke(electronMcpCallTool)
8485
const setLocale = useElectronEventaInvoke(i18nSetLocale)
8586
const isChatWindowRoute = () => route.path === '/chat'
87+
const isWidgetsWindowRoute = () => route.path === '/widgets'
88+
89+
async function refreshPluginRuntimeTools() {
90+
try {
91+
await pluginToolsStore.refresh()
92+
}
93+
catch (error) {
94+
console.warn('[App] Failed to refresh plugin runtime tools:', error)
95+
}
96+
}
97+
98+
watch(() => route.path, () => {
99+
contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindowRoute() ? 'client' : 'main')
100+
}, { immediate: true })
86101
87102
// NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers.
88103
pluginHostInspectorStore.setBridge({
89104
list: () => listPlugins(),
90-
setEnabled: payload => setPluginEnabled(payload),
91-
loadEnabled: () => loadEnabledPlugins(),
92-
load: payload => loadPlugin(payload),
93-
unload: payload => unloadPlugin(payload),
105+
setEnabled: async (payload) => {
106+
const result = await setPluginEnabled(payload)
107+
await refreshPluginRuntimeTools()
108+
return result
109+
},
110+
setAutoReload: payload => setPluginAutoReload(payload),
111+
loadEnabled: async () => {
112+
const result = await loadEnabledPlugins()
113+
await refreshPluginRuntimeTools()
114+
return result
115+
},
116+
load: async (payload) => {
117+
const result = await loadPlugin(payload)
118+
await refreshPluginRuntimeTools()
119+
return result
120+
},
121+
unload: async (payload) => {
122+
const result = await unloadPlugin(payload)
123+
await refreshPluginRuntimeTools()
124+
return result
125+
},
94126
inspect: () => inspectPluginHost(),
95127
})
96128
97-
// NOTICE: MCP tools are declared from stage-ui and executed during model streaming.
98-
// Register runtime bridge during setup to avoid missing bridge in early tool invocations.
99-
setMcpToolBridge({
100-
listTools: () => listMcpTools(),
101-
callTool: payload => callMcpTool(payload),
129+
// NOTICE: Runtime tool stores must register during setup so renderer consumers can see them
130+
// before `onMounted()` finishes the rest of the startup flow.
131+
void mcpToolsStore.refresh().catch((error) => {
132+
console.warn('[App] Failed to refresh MCP runtime tools:', error)
102133
})
134+
void refreshPluginRuntimeTools()
103135
104136
watch(language, () => {
105137
i18n.locale.value = language.value
@@ -143,8 +175,10 @@ onMounted(async () => {
143175
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
144176
if (!isChatWindowRoute()) {
145177
contextBridgeStore.initialize()
146-
characterOrchestratorStore.initialize()
147-
await startTrackingCursorPoint()
178+
if (!isWidgetsWindowRoute()) {
179+
characterOrchestratorStore.initialize()
180+
await startTrackingCursorPoint()
181+
}
148182
}
149183
150184
// Expose stage provider definitions to plugin host APIs.
@@ -176,7 +210,8 @@ onUnmounted(() => {
176210
if (!isChatWindowRoute()) {
177211
contextBridgeStore.dispose()
178212
}
179-
clearMcpToolBridge()
213+
mcpToolsStore.dispose()
214+
pluginToolsStore.dispose()
180215
})
181216
</script>
182217

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { Tool } from '@xsai/shared-chat'
2+
3+
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
4+
import { createPinia, setActivePinia } from 'pinia'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const invokeMocks = vi.hoisted(() => ({
8+
callMcpTool: vi.fn(async () => ({
9+
content: [{ type: 'text', text: 'ok' }],
10+
isError: false,
11+
})),
12+
listMcpTools: vi.fn(async () => [{
13+
serverName: 'filesystem',
14+
name: 'filesystem::search',
15+
toolName: 'search',
16+
description: 'Search files.',
17+
inputSchema: {
18+
type: 'object',
19+
properties: {},
20+
},
21+
}]),
22+
}))
23+
24+
vi.mock('@proj-airi/electron-vueuse', () => ({
25+
useElectronEventaInvoke: (event: { receiveEvent?: { id?: string } }) => {
26+
if (event?.receiveEvent?.id === 'eventa:invoke:electron:mcp:list-tools-receive')
27+
return invokeMocks.listMcpTools
28+
if (event?.receiveEvent?.id === 'eventa:invoke:electron:mcp:call-tool-receive')
29+
return invokeMocks.callMcpTool
30+
31+
throw new Error(`Unexpected eventa invoke: ${JSON.stringify(event)}`)
32+
},
33+
}))
34+
35+
describe('useTamagotchiMcpToolsStore', async () => {
36+
const { useTamagotchiMcpToolsStore } = await import('./mcp-tools')
37+
38+
beforeEach(() => {
39+
setActivePinia(createPinia())
40+
invokeMocks.listMcpTools.mockClear()
41+
invokeMocks.callMcpTool.mockClear()
42+
})
43+
44+
/**
45+
* @example
46+
* await store.refresh()
47+
* expect(llmToolsStore.toolsByProvider.mcp).toHaveLength(2)
48+
*/
49+
it('loads MCP tools, proxies execution, and clears them from the shared llm-tools store', async () => {
50+
const llmToolsStore = useLlmToolsStore()
51+
const store = useTamagotchiMcpToolsStore()
52+
const toolOptions = {} as Parameters<Tool['execute']>[1]
53+
54+
await store.refresh()
55+
56+
const mcpTools = llmToolsStore.toolsByProvider.mcp
57+
const listTools = mcpTools?.find(tool => tool.function.name === 'builtIn_mcpListTools')
58+
const callTool = mcpTools?.find(tool => tool.function.name === 'builtIn_mcpCallTool')
59+
60+
expect(mcpTools).toEqual([
61+
expect.objectContaining({ function: expect.objectContaining({ name: 'builtIn_mcpListTools' }) }),
62+
expect.objectContaining({ function: expect.objectContaining({ name: 'builtIn_mcpCallTool' }) }),
63+
])
64+
65+
const listResult = await listTools?.execute({}, toolOptions)
66+
const callResult = await callTool?.execute({
67+
name: 'filesystem::search',
68+
arguments: JSON.stringify({ query: 'hello', limit: 10 }),
69+
}, toolOptions)
70+
71+
expect(invokeMocks.listMcpTools).toHaveBeenCalledTimes(1)
72+
expect(invokeMocks.callMcpTool).toHaveBeenCalledWith({
73+
name: 'filesystem::search',
74+
arguments: { query: 'hello', limit: 10 },
75+
})
76+
expect(listResult).toEqual([{
77+
serverName: 'filesystem',
78+
name: 'filesystem::search',
79+
toolName: 'search',
80+
description: 'Search files.',
81+
inputSchema: {
82+
type: 'object',
83+
properties: {},
84+
},
85+
}])
86+
expect(callResult).toEqual({
87+
content: [{ type: 'text', text: 'ok' }],
88+
isError: false,
89+
})
90+
91+
store.dispose()
92+
93+
expect(llmToolsStore.toolsByProvider.mcp).toBeUndefined()
94+
})
95+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
2+
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
3+
import { createMcpTools } from '@proj-airi/stage-ui/tools/mcp'
4+
import { defineStore } from 'pinia'
5+
6+
import { electronMcpCallTool, electronMcpListTools } from '../../shared/eventa'
7+
8+
/**
9+
* Registers Electron-backed MCP tools into the shared LLM tools store.
10+
*
11+
* Use when:
12+
* - The Tamagotchi renderer needs live MCP tools during chat streaming
13+
*
14+
* Expects:
15+
* - Electron Eventa handlers for MCP listing and invocation are available
16+
*
17+
* Returns:
18+
* - Store actions for refreshing and disposing MCP runtime tools
19+
*/
20+
export const useTamagotchiMcpToolsStore = defineStore('tamagotchi-mcp-tools', () => {
21+
const llmToolsStore = useLlmToolsStore()
22+
const listMcpTools = useElectronEventaInvoke(electronMcpListTools)
23+
const callMcpTool = useElectronEventaInvoke(electronMcpCallTool)
24+
25+
async function refresh() {
26+
return llmToolsStore.registerTools('mcp', Promise.all(createMcpTools({
27+
listTools: () => listMcpTools(),
28+
callTool: payload => callMcpTool(payload),
29+
})))
30+
}
31+
32+
function dispose() {
33+
llmToolsStore.clearTools('mcp')
34+
}
35+
36+
return {
37+
dispose,
38+
refresh,
39+
}
40+
})
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { Tool } from '@xsai/shared-chat'
2+
3+
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
4+
import { createPinia, setActivePinia } from 'pinia'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const invokeMocks = vi.hoisted(() => ({
8+
invokePluginTool: vi.fn(async (payload: unknown) => payload),
9+
listPluginXsaiTools: vi.fn(async () => [
10+
{
11+
ownerPluginId: 'plugin-chess',
12+
name: 'play_chess',
13+
description: 'Play a chess move.',
14+
parameters: {
15+
type: 'object',
16+
properties: {},
17+
},
18+
},
19+
]),
20+
}))
21+
22+
vi.mock('@proj-airi/electron-vueuse', () => ({
23+
useElectronEventaInvoke: (event: { receiveEvent?: { id?: string } }) => {
24+
if (event?.receiveEvent?.id === 'eventa:invoke:electron:plugins:tools:list-xsai-receive')
25+
return invokeMocks.listPluginXsaiTools
26+
if (event?.receiveEvent?.id === 'eventa:invoke:electron:plugins:tools:invoke-receive')
27+
return invokeMocks.invokePluginTool
28+
29+
throw new Error(`Unexpected eventa invoke: ${JSON.stringify(event)}`)
30+
},
31+
}))
32+
33+
describe('useTamagotchiPluginToolsStore', async () => {
34+
const { useTamagotchiPluginToolsStore } = await import('./plugin-tools')
35+
36+
beforeEach(() => {
37+
setActivePinia(createPinia())
38+
invokeMocks.listPluginXsaiTools.mockClear()
39+
invokeMocks.invokePluginTool.mockClear()
40+
})
41+
42+
/**
43+
* @example
44+
* await store.refresh()
45+
* expect(llmToolsStore.toolsByProvider['plugin-tools']).toHaveLength(1)
46+
*/
47+
it('loads plugin xsai tools, proxies execution, and clears them from the shared llm-tools store', async () => {
48+
const llmToolsStore = useLlmToolsStore()
49+
const store = useTamagotchiPluginToolsStore()
50+
const toolOptions = {} as Parameters<Tool['execute']>[1]
51+
52+
await store.refresh()
53+
54+
const pluginTools = llmToolsStore.toolsByProvider['plugin-tools']
55+
const playChessTool = pluginTools?.find(tool => tool.function.name === 'play_chess')
56+
57+
expect(pluginTools).toEqual([
58+
expect.objectContaining({ function: expect.objectContaining({ name: 'play_chess' }) }),
59+
])
60+
61+
const executionResult = await playChessTool?.execute({
62+
move: 'e2e4',
63+
}, toolOptions)
64+
65+
expect(invokeMocks.invokePluginTool).toHaveBeenCalledWith({
66+
ownerPluginId: 'plugin-chess',
67+
name: 'play_chess',
68+
input: {
69+
move: 'e2e4',
70+
},
71+
})
72+
expect(executionResult).toEqual({
73+
ownerPluginId: 'plugin-chess',
74+
name: 'play_chess',
75+
input: {
76+
move: 'e2e4',
77+
},
78+
})
79+
80+
store.dispose()
81+
82+
expect(llmToolsStore.toolsByProvider['plugin-tools']).toBeUndefined()
83+
})
84+
})

0 commit comments

Comments
 (0)