refactor(stage-ui,stage-tamagotchi): have plugin-tools, mcp-tools to register directly to llm.ts
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user