feat(stage-*,plugin-*): retriable tool call
This commit is contained in:
@@ -261,18 +261,6 @@ function createExtensionGameletKitManifest(entrypoint: string, id = 'test-extens
|
||||
function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {}) {
|
||||
const respondToRequests = options.respondToRequests ?? true
|
||||
const widgetSnapshots = new Map<string, WidgetSnapshot>()
|
||||
const widgetEventListeners = new Set<(event: { id: string, event: Record<string, unknown> }) => void>()
|
||||
const publishWidgetEvent = vi.fn((id: string, event: Record<string, unknown>) => {
|
||||
for (const listener of widgetEventListeners) {
|
||||
listener({ id, event })
|
||||
}
|
||||
})
|
||||
const onWidgetEvent = vi.fn((listener: (event: { id: string, event: Record<string, unknown> }) => void) => {
|
||||
widgetEventListeners.add(listener)
|
||||
return () => {
|
||||
widgetEventListeners.delete(listener)
|
||||
}
|
||||
})
|
||||
const openWindow = vi.fn(async (_params?: { id?: string }) => {})
|
||||
const pushWidget = vi.fn(async (payload: WidgetsAddPayload) => {
|
||||
const snapshot: WidgetSnapshot = {
|
||||
@@ -302,27 +290,14 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {
|
||||
windowSize: payload.windowSize ?? existing.windowSize,
|
||||
ttlMs: payload.ttlMs ?? existing.ttlMs,
|
||||
})
|
||||
|
||||
const componentProps = payload.componentProps as Record<string, unknown> | undefined
|
||||
const request = componentProps?.payload && typeof componentProps.payload === 'object' && !Array.isArray(componentProps.payload)
|
||||
? (componentProps.payload as Record<string, unknown>).request
|
||||
: undefined
|
||||
if (respondToRequests && request && typeof request === 'object' && !Array.isArray(request) && typeof (request as Record<string, unknown>).requestId === 'string') {
|
||||
const requestId = (request as Record<string, unknown>).requestId
|
||||
queueMicrotask(() => {
|
||||
publishWidgetEvent(payload.id, {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId,
|
||||
ready: true,
|
||||
fen: 'fen-after-request',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
const requestWidgetIframe = vi.fn()
|
||||
requestWidgetIframe.mockImplementation(async () => {
|
||||
if (!respondToRequests) {
|
||||
throw new Error('Widget iframe request was not handled.')
|
||||
}
|
||||
|
||||
return { fen: 'fen-after-request' }
|
||||
})
|
||||
const removeWidget = vi.fn(async (id: string) => {
|
||||
widgetSnapshots.delete(id)
|
||||
@@ -337,8 +312,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {
|
||||
updateWidget,
|
||||
removeWidget,
|
||||
getWidgetSnapshot,
|
||||
publishWidgetEvent,
|
||||
onWidgetEvent,
|
||||
requestWidgetIframe,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1244,7 +1218,7 @@ describe('setupExtensionHost', () => {
|
||||
' await gamelets.orchestration.configure(\'kit-module:board\', { command: { requestId: \'ignored-by-test-double\' } })',
|
||||
' const snapshot = await gamelets.orchestration.request(\'kit-module:board\', { action: \'snapshot\' }, { timeoutMs: 1000 })',
|
||||
' if (snapshot.fen !== \'fen-after-request\') {',
|
||||
' throw new Error(\'Expected request to resolve from response event\')',
|
||||
' throw new Error(\'Expected request to resolve from widget iframe request\')',
|
||||
' }',
|
||||
' if (!(await gamelets.orchestration.isOpen(\'kit-module:board\'))) {',
|
||||
' throw new Error(\'Expected gamelet to be open before close\')',
|
||||
@@ -1282,26 +1256,11 @@ describe('setupExtensionHost', () => {
|
||||
payload: { command: { requestId: 'ignored-by-test-double' } },
|
||||
},
|
||||
})
|
||||
expect(widgetsManager.updateWidget).toHaveBeenCalledWith({
|
||||
id: 'kit-module:board',
|
||||
componentProps: {
|
||||
moduleId: 'kit-module:board',
|
||||
payload: {
|
||||
request: {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'request',
|
||||
},
|
||||
responseRoute: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
requestId: expect.any(String),
|
||||
payload: { action: 'snapshot' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith(
|
||||
'kit-module:board',
|
||||
{ action: 'snapshot' },
|
||||
{ timeoutMs: 1000 },
|
||||
)
|
||||
expect(widgetsManager.getWidgetSnapshot).toHaveBeenCalledWith('kit-module:board')
|
||||
expect(widgetsManager.removeWidget).toHaveBeenCalledWith('kit-module:board')
|
||||
})
|
||||
@@ -1346,57 +1305,41 @@ describe('setupExtensionHost', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(request).rejects.toThrow('Gamelet request failed.')
|
||||
* await expect(request).rejects.toThrow('Board rejected the snapshot request.')
|
||||
*/
|
||||
it('rejects gamelet requests when the iframe response reports failure', async () => {
|
||||
it('propagates gamelet request rejection from the widget iframe manager', async () => {
|
||||
const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false })
|
||||
widgetsManager.requestWidgetIframe.mockRejectedValueOnce(new Error('Board rejected the snapshot request.'))
|
||||
const gamelets = createGameletOrchestrationRuntime(widgetsManager)
|
||||
|
||||
await gamelets.open('kit-module:board')
|
||||
const request = gamelets.request('kit-module:board', { action: 'snapshot' })
|
||||
const updatePayload = widgetsManager.updateWidget.mock.calls.at(-1)?.[0]
|
||||
const requestEnvelope = updatePayload?.componentProps?.payload?.request
|
||||
if (!requestEnvelope || typeof requestEnvelope !== 'object' || Array.isArray(requestEnvelope) || typeof requestEnvelope.requestId !== 'string') {
|
||||
throw new Error('Expected gamelet request envelope in widget props.')
|
||||
}
|
||||
|
||||
widgetsManager.publishWidgetEvent('kit-module:board', {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: requestEnvelope.requestId,
|
||||
ok: false,
|
||||
message: 'Board rejected the snapshot request.',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(request).rejects.toThrow('Board rejected the snapshot request.')
|
||||
await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Board rejected the snapshot request.')
|
||||
expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith(
|
||||
'kit-module:board',
|
||||
{ action: 'snapshot' },
|
||||
{ timeoutMs: 30000 },
|
||||
)
|
||||
gamelets.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(request).rejects.toThrow('Gamelet request timed out after 30000ms.')
|
||||
* expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 })
|
||||
*/
|
||||
it('uses the default gamelet request timeout when no timeout is provided', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false })
|
||||
const gamelets = createGameletOrchestrationRuntime(widgetsManager)
|
||||
const { widgetsManager } = createWidgetsManagerDouble()
|
||||
const gamelets = createGameletOrchestrationRuntime(widgetsManager)
|
||||
|
||||
await gamelets.open('kit-module:board')
|
||||
const request = gamelets.request('kit-module:board', { action: 'snapshot' })
|
||||
const rejection = expect(request).rejects.toThrow('Gamelet request timed out after 30000ms.')
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
await gamelets.open('kit-module:board')
|
||||
await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).resolves.toEqual({ fen: 'fen-after-request' })
|
||||
|
||||
await rejection
|
||||
gamelets.dispose()
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith(
|
||||
'kit-module:board',
|
||||
{ action: 'snapshot' },
|
||||
{ timeoutMs: 30000 },
|
||||
)
|
||||
gamelets.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1409,109 +1352,25 @@ describe('setupExtensionHost', () => {
|
||||
|
||||
await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.')
|
||||
expect(widgetsManager.updateWidget).not.toHaveBeenCalled()
|
||||
expect(widgetsManager.requestWidgetIframe).not.toHaveBeenCalled()
|
||||
gamelets.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(request).resolves.toEqual(expect.objectContaining({ fen: 'fen-after-request' }))
|
||||
*/
|
||||
it('ignores gamelet responses from a different widget id', async () => {
|
||||
const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false })
|
||||
it('handles gamelet requests without legacy widget response event APIs', async () => {
|
||||
const { widgetsManager } = createWidgetsManagerDouble()
|
||||
const gamelets = createGameletOrchestrationRuntime(widgetsManager)
|
||||
|
||||
await gamelets.open('kit-module:board')
|
||||
const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 })
|
||||
const updatePayload = widgetsManager.updateWidget.mock.calls.at(-1)?.[0]
|
||||
const requestEnvelope = updatePayload?.componentProps?.payload?.request
|
||||
if (!requestEnvelope || typeof requestEnvelope !== 'object' || Array.isArray(requestEnvelope) || typeof requestEnvelope.requestId !== 'string') {
|
||||
throw new Error('Expected gamelet request envelope in widget props.')
|
||||
}
|
||||
await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).resolves.toEqual({ fen: 'fen-after-request' })
|
||||
|
||||
widgetsManager.publishWidgetEvent('kit-module:other-board', {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: requestEnvelope.requestId,
|
||||
fen: 'wrong-board',
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
widgetsManager.publishWidgetEvent('kit-module:board', {
|
||||
type: 'response',
|
||||
requestId: requestEnvelope.requestId,
|
||||
fen: 'legacy-top-level',
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
widgetsManager.publishWidgetEvent('kit-module:board', {
|
||||
route: {
|
||||
namespace: 'airi.plugin.other',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: requestEnvelope.requestId,
|
||||
fen: 'wrong-namespace',
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
widgetsManager.publishWidgetEvent('kit-module:board', {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: requestEnvelope.requestId,
|
||||
fen: 'fen-after-request',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(request).resolves.toEqual({ fen: 'fen-after-request' })
|
||||
expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith(
|
||||
'kit-module:board',
|
||||
{ action: 'snapshot' },
|
||||
{ timeoutMs: 30000 },
|
||||
)
|
||||
gamelets.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(request).rejects.toThrow('Gamelet was closed before the request completed.')
|
||||
*/
|
||||
it('rejects pending gamelet requests when the widget closes', async () => {
|
||||
const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false })
|
||||
const gamelets = createGameletOrchestrationRuntime(widgetsManager)
|
||||
|
||||
await gamelets.open('kit-module:board')
|
||||
const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 })
|
||||
const rejection = expect(request).rejects.toThrow('Gamelet was closed before the request completed.')
|
||||
await gamelets.close('kit-module:board')
|
||||
|
||||
await rejection
|
||||
expect(widgetsManager.removeWidget).toHaveBeenCalledWith('kit-module:board')
|
||||
gamelets.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(unsubscribe).toHaveBeenCalled()
|
||||
* await expect(request).rejects.toThrow('Gamelet orchestration runtime was disposed before the request completed.')
|
||||
*/
|
||||
it('unsubscribes and rejects pending gamelet requests on dispose', async () => {
|
||||
const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false })
|
||||
const unsubscribe = vi.fn()
|
||||
widgetsManager.onWidgetEvent.mockReturnValueOnce(unsubscribe)
|
||||
const gamelets = createGameletOrchestrationRuntime(widgetsManager)
|
||||
|
||||
await gamelets.open('kit-module:board')
|
||||
const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 })
|
||||
const rejection = expect(request).rejects.toThrow('Gamelet orchestration runtime was disposed before the request completed.')
|
||||
gamelets.dispose()
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalled()
|
||||
await rejection
|
||||
})
|
||||
|
||||
it('rejects module announce when the kit runtime does not match the host runtime', async () => {
|
||||
const { host } = await setupExtensionHost()
|
||||
|
||||
|
||||
+10
-162
@@ -3,34 +3,22 @@ import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { ExtensionHostGameletWidgetsManager } from '../../types'
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30000
|
||||
const GAMELET_ROUTE_NAMESPACE = 'airi.plugin.gamelet'
|
||||
|
||||
export interface GameletOrchestrationRuntime extends NonNullable<GameletKitRuntime['gamelets']> {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
bindingId: string
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Electron host implementation for gamelet lifecycle and request calls.
|
||||
*
|
||||
* Use when:
|
||||
* - Built-in `kit.gamelet` clients need to open iframe-backed extension UI widgets
|
||||
* - Extension-side gamelet handles need request/response orchestration through widget events
|
||||
* - Extension-side gamelet handles need request/response orchestration through widget iframe requests
|
||||
*
|
||||
* Expects:
|
||||
* - Widget ids are the same values as gamelet binding ids
|
||||
* - Widget-side response events echo the original `requestId` at the top level or under `payload`
|
||||
* - The widget manager owns iframe request correlation, timeout, and cleanup
|
||||
*
|
||||
* Returns:
|
||||
* - A gamelet orchestration runtime backed by the stage widget manager
|
||||
@@ -38,50 +26,6 @@ interface PendingRequest {
|
||||
export function createGameletOrchestrationRuntime(
|
||||
widgetsManager: ExtensionHostGameletWidgetsManager,
|
||||
): GameletOrchestrationRuntime {
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
const rejectPendingForBinding = (bindingId: string, message: string) => {
|
||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||
if (pending.bindingId !== bindingId) {
|
||||
continue
|
||||
}
|
||||
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error(message))
|
||||
}
|
||||
}
|
||||
|
||||
const rejectAllPending = (message: string) => {
|
||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error(message))
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = widgetsManager.onWidgetEvent(({ id, event }) => {
|
||||
const response = readRequestResponse(event)
|
||||
if (!response) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(response.requestId)
|
||||
if (!pending || pending.bindingId !== id) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingRequests.delete(response.requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
|
||||
if (response.ok === false) {
|
||||
pending.reject(new Error(readResponseErrorMessage(response.value)))
|
||||
return
|
||||
}
|
||||
|
||||
pending.resolve(response.value)
|
||||
})
|
||||
|
||||
return {
|
||||
async open(bindingId, payload) {
|
||||
const componentProps = createComponentProps(bindingId, payload ?? {})
|
||||
@@ -115,64 +59,21 @@ export function createGameletOrchestrationRuntime(
|
||||
throw new Error(`Gamelet \`${bindingId}\` is not open.`)
|
||||
}
|
||||
|
||||
const requestId = randomUUID()
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
||||
|
||||
const response = new Promise<TResponse>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId)
|
||||
reject(new Error(`Gamelet request timed out after ${timeoutMs}ms.`))
|
||||
}, timeoutMs)
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
bindingId,
|
||||
resolve: value => resolve(value as TResponse),
|
||||
reject,
|
||||
timeout,
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await widgetsManager.updateWidget({
|
||||
id: bindingId,
|
||||
componentProps: createComponentProps(bindingId, {
|
||||
request: {
|
||||
route: {
|
||||
namespace: GAMELET_ROUTE_NAMESPACE,
|
||||
name: 'request',
|
||||
},
|
||||
responseRoute: {
|
||||
namespace: GAMELET_ROUTE_NAMESPACE,
|
||||
name: 'response',
|
||||
},
|
||||
requestId,
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
const pending = pendingRequests.get(requestId)
|
||||
if (pending) {
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error(errorMessageFrom(error) ?? 'Failed to publish gamelet request.'))
|
||||
}
|
||||
}
|
||||
|
||||
return await response
|
||||
return await widgetsManager.requestWidgetIframe<TResponse & Record<string, unknown>>(
|
||||
bindingId,
|
||||
payload,
|
||||
{
|
||||
timeoutMs: options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
) as TResponse
|
||||
},
|
||||
async close(bindingId) {
|
||||
rejectPendingForBinding(bindingId, 'Gamelet was closed before the request completed.')
|
||||
await widgetsManager.removeWidget(bindingId)
|
||||
},
|
||||
async isOpen(bindingId) {
|
||||
return Boolean(widgetsManager.getWidgetSnapshot(bindingId))
|
||||
},
|
||||
dispose() {
|
||||
unsubscribe()
|
||||
rejectAllPending('Gamelet orchestration runtime was disposed before the request completed.')
|
||||
},
|
||||
dispose() {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,56 +83,3 @@ function createComponentProps(bindingId: string, payload: HostDataRecord): HostD
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
function readRequestResponse(event: Record<string, unknown>): { requestId: string, ok?: boolean, value: unknown } | undefined {
|
||||
const route = event.route
|
||||
if (!route || typeof route !== 'object' || Array.isArray(route)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const routeRecord = route as Record<string, unknown>
|
||||
if (routeRecord.namespace !== GAMELET_ROUTE_NAMESPACE || routeRecord.name !== 'response') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload = event.payload
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadRecord = payload as Record<string, unknown>
|
||||
if (typeof payloadRecord.requestId !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: payloadRecord.requestId,
|
||||
ok: typeof payloadRecord.ok === 'boolean' ? payloadRecord.ok : undefined,
|
||||
value: readResponseValue(payloadRecord),
|
||||
}
|
||||
}
|
||||
|
||||
function readResponseValue(response: Record<string, unknown>): unknown {
|
||||
if ('result' in response) {
|
||||
return response.result
|
||||
}
|
||||
|
||||
const { type: _type, requestId: _requestId, ...value } = response
|
||||
return value
|
||||
}
|
||||
|
||||
function readResponseErrorMessage(response: unknown): string {
|
||||
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
||||
return 'Gamelet request failed.'
|
||||
}
|
||||
|
||||
const responseRecord = response as Record<string, unknown>
|
||||
if (typeof responseRecord.error === 'string') {
|
||||
return responseRecord.error
|
||||
}
|
||||
if (typeof responseRecord.message === 'string') {
|
||||
return responseRecord.message
|
||||
}
|
||||
|
||||
return 'Gamelet request failed.'
|
||||
}
|
||||
|
||||
@@ -48,8 +48,11 @@ export interface ExtensionHostGameletWidgetsManager {
|
||||
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
|
||||
removeWidget: (id: string) => Promise<void>
|
||||
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
|
||||
publishWidgetEvent: (id: string, event: Record<string, unknown>) => void
|
||||
onWidgetEvent: (listener: (event: { id: string, event: Record<string, unknown> }) => void) => () => void
|
||||
requestWidgetIframe: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
|
||||
id: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number },
|
||||
) => Promise<TResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import { createContext } from '@moeru/eventa'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa'
|
||||
import { createWidgetsService } from './index'
|
||||
|
||||
function createWindow(id: number): BrowserWindow {
|
||||
return {
|
||||
webContents: {
|
||||
id,
|
||||
},
|
||||
} as BrowserWindow
|
||||
}
|
||||
|
||||
function createWidgetsManager() {
|
||||
return {
|
||||
clearWidgets: vi.fn(),
|
||||
fetchWidget: vi.fn(),
|
||||
getWindow: vi.fn(),
|
||||
getWidgetSnapshot: vi.fn(),
|
||||
hideWindow: vi.fn(),
|
||||
onWidgetEvent: vi.fn(),
|
||||
openWindow: vi.fn(),
|
||||
prepareWidgetWindow: vi.fn(),
|
||||
publishWidgetEvent: vi.fn(),
|
||||
publishWidgetIframeRequestResult: vi.fn(),
|
||||
pushWidget: vi.fn(),
|
||||
removeWidget: vi.fn(),
|
||||
requestWidgetIframe: vi.fn(),
|
||||
updateWidget: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('createWidgetsService', () => {
|
||||
it('routes iframe request results from the widgets window to the manager', () => {
|
||||
const context = createContext()
|
||||
const widgetsManager = createWidgetsManager()
|
||||
const window = createWindow(1)
|
||||
createWidgetsService({
|
||||
context: context as Parameters<typeof createWidgetsService>[0]['context'],
|
||||
widgetsManager,
|
||||
window,
|
||||
})
|
||||
|
||||
context.emit(widgetsIframeRequestResultEvent, {
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
}, {
|
||||
raw: {
|
||||
ipcMainEvent: {
|
||||
sender: { id: 1 },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores iframe request results from other windows', () => {
|
||||
const context = createContext()
|
||||
const widgetsManager = createWidgetsManager()
|
||||
const window = createWindow(1)
|
||||
createWidgetsService({
|
||||
context: context as Parameters<typeof createWidgetsService>[0]['context'],
|
||||
widgetsManager,
|
||||
window,
|
||||
})
|
||||
|
||||
context.emit(widgetsIframeRequestResultEvent, {
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
}, {
|
||||
raw: {
|
||||
ipcMainEvent: {
|
||||
sender: { id: 2 },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
expect(widgetsManager.publishWidgetIframeRequestResult).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
widgetsFetch,
|
||||
widgetsHideWindow,
|
||||
widgetsIframePublish,
|
||||
widgetsIframeRequestResultEvent,
|
||||
widgetsOpenWindow,
|
||||
widgetsPrepareWindow,
|
||||
widgetsRemove,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
normalizeOptionalWidgetId,
|
||||
normalizeRequiredWidgetId,
|
||||
validateWidgetIframeEvent,
|
||||
validateWidgetIframeRequestResult,
|
||||
validateWidgetsAddPayload,
|
||||
validateWidgetsUpdatePayload,
|
||||
} from './validation'
|
||||
@@ -57,6 +59,15 @@ function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow)
|
||||
* -> {@link WidgetsWindowManager.pushWidget}
|
||||
*/
|
||||
export function createWidgetsService(params: { context: ReturnType<typeof createContext>['context'], widgetsManager: WidgetsWindowManager, window: BrowserWindow }) {
|
||||
params.context.on(widgetsIframeRequestResultEvent, (event, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return
|
||||
|
||||
params.widgetsManager.publishWidgetIframeRequestResult(
|
||||
validateWidgetIframeRequestResult(event.body),
|
||||
)
|
||||
})
|
||||
|
||||
defineInvokeHandlers(
|
||||
params.context,
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeOptionalWidgetId,
|
||||
normalizeRequiredWidgetId,
|
||||
validateWidgetIframeRequestResult,
|
||||
validateWidgetsAddPayload,
|
||||
validateWidgetsUpdatePayload,
|
||||
} from './validation'
|
||||
@@ -111,4 +112,48 @@ describe('widget invoke validation', () => {
|
||||
expect(() => normalizeRequiredWidgetId(' ', 'id required')).toThrow('id required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateWidgetIframeRequestResult', () => {
|
||||
it('normalizes successful iframe request results', () => {
|
||||
expect(validateWidgetIframeRequestResult({
|
||||
id: ' kit-module:board ',
|
||||
requestId: ' req-1 ',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
})).toEqual({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes failed iframe request results', () => {
|
||||
expect(validateWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: false,
|
||||
error: 'Board rejected request.',
|
||||
})).toEqual({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: false,
|
||||
error: 'Board rejected request.',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed iframe request results', () => {
|
||||
expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.')
|
||||
expect(() => validateWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
})).toThrow('iframe request result payload must be a plain object.')
|
||||
expect(() => validateWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: false,
|
||||
})).toThrow('iframe request result error is required.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
WidgetsAddPayload,
|
||||
WidgetsIframeRequestResultPayload,
|
||||
WidgetsUpdatePayload,
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
@@ -183,3 +184,61 @@ export function validateWidgetIframeEvent(event: unknown): Record<string, unknow
|
||||
|
||||
return event as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates renderer-to-main iframe request results before they settle pending gamelet requests.
|
||||
*
|
||||
* Use when:
|
||||
* - The widgets renderer reports a response from a mounted extension iframe
|
||||
*
|
||||
* Expects:
|
||||
* - `id` and `requestId` are non-empty strings
|
||||
* - Successful results contain a plain response record
|
||||
* - Failed results contain an error message
|
||||
*
|
||||
* Returns:
|
||||
* - A discriminated request result safe to pass into the widgets manager
|
||||
*/
|
||||
export function validateWidgetIframeRequestResult(result: unknown): WidgetsIframeRequestResultPayload {
|
||||
if (!isPlainObject(result)) {
|
||||
throw new Error('iframe request result must be a plain object.')
|
||||
}
|
||||
|
||||
const id = normalizeWidgetId(typeof result.id === 'string' ? result.id : undefined)
|
||||
if (!id) {
|
||||
throw new Error('iframe request result id is required.')
|
||||
}
|
||||
|
||||
const requestId = normalizeWidgetId(typeof result.requestId === 'string' ? result.requestId : undefined)
|
||||
if (!requestId) {
|
||||
throw new Error('iframe request result requestId is required.')
|
||||
}
|
||||
|
||||
if (result.ok === true) {
|
||||
if (!isPlainObject(result.result)) {
|
||||
throw new Error('iframe request result payload must be a plain object.')
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
requestId,
|
||||
ok: true,
|
||||
result: result.result,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok === false) {
|
||||
if (typeof result.error !== 'string' || !result.error.trim()) {
|
||||
throw new Error('iframe request result error is required.')
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
requestId,
|
||||
ok: false,
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('iframe request result ok must be a boolean.')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
WidgetsIframeRequestPayload,
|
||||
WidgetsIframeRequestResultPayload,
|
||||
} from '../../../shared/eventa'
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const DEFAULT_WIDGET_IFRAME_REQUEST_TIMEOUT_MS = 30000
|
||||
const WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE = 'Gamelet was closed before the request completed.'
|
||||
|
||||
interface PendingWidgetIframeRequest {
|
||||
id: string
|
||||
resolve: (result: Record<string, unknown>) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime hooks used by the widget iframe request coordinator.
|
||||
*/
|
||||
export interface WidgetIframeRequestCoordinatorOptions {
|
||||
/** Emits the main-to-renderer iframe request event after pending state is registered. */
|
||||
emitRequest: (payload: WidgetsIframeRequestPayload) => void
|
||||
/** Returns whether the widget id currently has a mounted main-process record. */
|
||||
hasWidget: (id: string) => boolean
|
||||
/** Returns whether a renderer relay is available to receive iframe request events. */
|
||||
hasRelay: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates pending request state for main-to-widget-iframe requests.
|
||||
*
|
||||
* The widgets renderer is an asynchronous relay between Electron main and the mounted iframe,
|
||||
* so this helper owns the correlation, timeout, widget-id isolation, and close cleanup policy
|
||||
* that would otherwise be hidden inside the window manager's Electron setup code.
|
||||
*/
|
||||
export function createWidgetIframeRequestCoordinator(options: WidgetIframeRequestCoordinatorOptions) {
|
||||
const pendingRequests = new Map<string, PendingWidgetIframeRequest>()
|
||||
|
||||
function settlePendingRequest(requestId: string, settle: (pending: PendingWidgetIframeRequest) => void) {
|
||||
const pending = pendingRequests.get(requestId)
|
||||
if (!pending)
|
||||
return undefined
|
||||
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
settle(pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
function requestWidgetIframe<TResponse extends Record<string, unknown> = Record<string, unknown>>(
|
||||
id: string,
|
||||
payload: Record<string, unknown>,
|
||||
requestOptions?: { timeoutMs?: number },
|
||||
): Promise<TResponse> {
|
||||
if (!options.hasWidget(id))
|
||||
return Promise.reject(new Error(`Gamelet \`${id}\` is not open.`))
|
||||
if (!options.hasRelay())
|
||||
return Promise.reject(new Error('Gamelet iframe relay is not available.'))
|
||||
|
||||
const requestId = randomUUID()
|
||||
const timeoutMs = requestOptions?.timeoutMs ?? DEFAULT_WIDGET_IFRAME_REQUEST_TIMEOUT_MS
|
||||
|
||||
const response = new Promise<TResponse>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId)
|
||||
reject(new Error(`Gamelet request timed out after ${timeoutMs}ms.`))
|
||||
}, timeoutMs)
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
id,
|
||||
resolve: result => resolve(result as TResponse),
|
||||
reject,
|
||||
timeout,
|
||||
})
|
||||
})
|
||||
|
||||
options.emitRequest({
|
||||
id,
|
||||
requestId,
|
||||
payload: payload as WidgetsIframeRequestPayload['payload'],
|
||||
timeoutMs,
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
function publishWidgetIframeRequestResult(result: WidgetsIframeRequestResultPayload) {
|
||||
const pending = pendingRequests.get(result.requestId)
|
||||
if (!pending || pending.id !== result.id)
|
||||
return
|
||||
|
||||
settlePendingRequest(result.requestId, (settled) => {
|
||||
if (result.ok) {
|
||||
settled.resolve(result.result)
|
||||
return
|
||||
}
|
||||
|
||||
settled.reject(new Error(result.error))
|
||||
})
|
||||
}
|
||||
|
||||
function rejectPendingWidgetIframeRequests(id: string, message = WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE) {
|
||||
for (const [requestId, pending] of pendingRequests) {
|
||||
if (pending.id !== id)
|
||||
continue
|
||||
|
||||
settlePendingRequest(requestId, settled => settled.reject(new Error(message)))
|
||||
}
|
||||
}
|
||||
|
||||
function rejectAllPendingWidgetIframeRequests(message = WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE) {
|
||||
for (const requestId of pendingRequests.keys()) {
|
||||
settlePendingRequest(requestId, settled => settled.reject(new Error(message)))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestWidgetIframe,
|
||||
publishWidgetIframeRequestResult,
|
||||
rejectPendingWidgetIframeRequests,
|
||||
rejectAllPendingWidgetIframeRequests,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { WidgetWindowSize } from '../../../shared/eventa'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size'
|
||||
import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator'
|
||||
|
||||
describe('normalizeWidgetWindowSize', () => {
|
||||
it('returns undefined for missing or unusable base sizes', () => {
|
||||
@@ -51,3 +52,152 @@ describe('normalizeWidgetWindowSize', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetIframeRequestCoordinator', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects immediately when the target widget is not open', async () => {
|
||||
const emitRequest = vi.fn()
|
||||
const coordinator = createWidgetIframeRequestCoordinator({
|
||||
emitRequest,
|
||||
hasWidget: () => false,
|
||||
hasRelay: () => true,
|
||||
})
|
||||
|
||||
await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.')
|
||||
expect(emitRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits a correlated iframe request and resolves only the matching successful result', async () => {
|
||||
const emitRequest = vi.fn()
|
||||
const coordinator = createWidgetIframeRequestCoordinator({
|
||||
emitRequest,
|
||||
hasWidget: id => id === 'kit-module:board',
|
||||
hasRelay: () => true,
|
||||
})
|
||||
|
||||
const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })
|
||||
const emitted = emitRequest.mock.calls[0]?.[0]
|
||||
|
||||
expect(emitted).toEqual({
|
||||
id: 'kit-module:board',
|
||||
requestId: expect.any(String),
|
||||
payload: { action: 'snapshot' },
|
||||
timeoutMs: 30000,
|
||||
})
|
||||
|
||||
coordinator.publishWidgetIframeRequestResult({
|
||||
id: 'kit-module:other-board',
|
||||
requestId: emitted.requestId,
|
||||
ok: true,
|
||||
result: { fen: 'wrong-board' },
|
||||
})
|
||||
coordinator.publishWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'unknown-request',
|
||||
ok: true,
|
||||
result: { fen: 'unknown-request' },
|
||||
})
|
||||
coordinator.publishWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
requestId: emitted.requestId,
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
})
|
||||
|
||||
await expect(request).resolves.toEqual({ fen: 'fen-after-request' })
|
||||
})
|
||||
|
||||
it('rejects a matching failed iframe result', async () => {
|
||||
const emitRequest = vi.fn()
|
||||
const coordinator = createWidgetIframeRequestCoordinator({
|
||||
emitRequest,
|
||||
hasWidget: () => true,
|
||||
hasRelay: () => true,
|
||||
})
|
||||
|
||||
const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })
|
||||
const emitted = emitRequest.mock.calls[0]?.[0]
|
||||
coordinator.publishWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
requestId: emitted.requestId,
|
||||
ok: false,
|
||||
error: 'Board rejected the snapshot request.',
|
||||
})
|
||||
|
||||
await expect(request).rejects.toThrow('Board rejected the snapshot request.')
|
||||
})
|
||||
|
||||
it('rejects timed out requests and removes their pending state', async () => {
|
||||
vi.useFakeTimers()
|
||||
const emitRequest = vi.fn()
|
||||
const coordinator = createWidgetIframeRequestCoordinator({
|
||||
emitRequest,
|
||||
hasWidget: () => true,
|
||||
hasRelay: () => true,
|
||||
})
|
||||
|
||||
const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 50 })
|
||||
const emitted = emitRequest.mock.calls[0]?.[0]
|
||||
const rejection = expect(request).rejects.toThrow('Gamelet request timed out after 50ms.')
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await rejection
|
||||
|
||||
coordinator.publishWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
requestId: emitted.requestId,
|
||||
ok: true,
|
||||
result: { fen: 'late-result' },
|
||||
})
|
||||
|
||||
await expect(request).rejects.toThrow('Gamelet request timed out after 50ms.')
|
||||
})
|
||||
|
||||
it('rejects pending requests for a removed widget', async () => {
|
||||
const emitRequest = vi.fn()
|
||||
const coordinator = createWidgetIframeRequestCoordinator({
|
||||
emitRequest,
|
||||
hasWidget: () => true,
|
||||
hasRelay: () => true,
|
||||
})
|
||||
|
||||
const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 })
|
||||
const rejection = expect(request).rejects.toThrow('Gamelet was closed before the request completed.')
|
||||
coordinator.rejectPendingWidgetIframeRequests('kit-module:board')
|
||||
|
||||
await rejection
|
||||
})
|
||||
|
||||
it('rejects immediately when no renderer relay is available', async () => {
|
||||
const emitRequest = vi.fn()
|
||||
const coordinator = createWidgetIframeRequestCoordinator({
|
||||
emitRequest,
|
||||
hasWidget: () => true,
|
||||
hasRelay: () => false,
|
||||
})
|
||||
|
||||
await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet iframe relay is not available.')
|
||||
expect(emitRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects all pending requests when the widgets window closes', async () => {
|
||||
const emitRequest = vi.fn()
|
||||
const coordinator = createWidgetIframeRequestCoordinator({
|
||||
emitRequest,
|
||||
hasWidget: () => true,
|
||||
hasRelay: () => true,
|
||||
})
|
||||
|
||||
const firstRequest = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 })
|
||||
const secondRequest = coordinator.requestWidgetIframe('kit-module:clock', { action: 'snapshot' }, { timeoutMs: 30000 })
|
||||
|
||||
const firstRejection = expect(firstRequest).rejects.toThrow('Gamelet was closed before the request completed.')
|
||||
const secondRejection = expect(secondRequest).rejects.toThrow('Gamelet was closed before the request completed.')
|
||||
coordinator.rejectAllPendingWidgetIframeRequests()
|
||||
|
||||
await firstRejection
|
||||
await secondRejection
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { InferOutput } from 'valibot'
|
||||
|
||||
import type {
|
||||
WidgetsAddPayload,
|
||||
WidgetsIframeRequestResultPayload,
|
||||
WidgetSnapshot,
|
||||
WidgetsUpdatePayload,
|
||||
} from '../../../shared/eventa'
|
||||
@@ -21,12 +22,13 @@ import { number, object, optional } from 'valibot'
|
||||
|
||||
import icon from '../../../../resources/icon.png?asset'
|
||||
|
||||
import { widgetsClearEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa'
|
||||
import { widgetsClearEvent, widgetsIframeRequestEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa'
|
||||
import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size'
|
||||
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
|
||||
import { createConfig } from '../../libs/electron/persistence'
|
||||
import { createReusableWindow } from '../../libs/electron/window-manager'
|
||||
import { spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window'
|
||||
import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator'
|
||||
import { setupWidgetsWindowInvokes } from './rpc/index.electron'
|
||||
|
||||
/**
|
||||
@@ -140,6 +142,36 @@ export interface WidgetsWindowManager {
|
||||
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
|
||||
publishWidgetEvent: (id: string, event: Record<string, unknown>) => void
|
||||
onWidgetEvent: (listener: (event: { id: string, event: Record<string, unknown> }) => void) => () => void
|
||||
/**
|
||||
* Sends a correlated request to a mounted widget iframe through the widgets renderer.
|
||||
*
|
||||
* Use when:
|
||||
* - Main-process gamelet orchestration needs a response from iframe code
|
||||
*
|
||||
* Expects:
|
||||
* - `id` references an open widget with a mounted iframe relay
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves with the iframe response record, or rejects on timeout, close, or iframe error
|
||||
*/
|
||||
requestWidgetIframe: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
|
||||
id: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number },
|
||||
) => Promise<TResponse>
|
||||
/**
|
||||
* Publishes a renderer-to-main iframe request result into the pending request coordinator.
|
||||
*
|
||||
* Use when:
|
||||
* - The widgets renderer reports a completed iframe request
|
||||
*
|
||||
* Expects:
|
||||
* - `result.requestId` matches a request previously emitted by {@link WidgetsWindowManager.requestWidgetIframe}
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing; unknown or mismatched results are ignored
|
||||
*/
|
||||
publishWidgetIframeRequestResult: (result: WidgetsIframeRequestResultPayload) => void
|
||||
/**
|
||||
* Reserves a widget id before content is pushed into the widgets window.
|
||||
*
|
||||
@@ -273,6 +305,11 @@ export function setupWidgetsWindowManager(params: {
|
||||
const widgetRecords = new Map<string, WidgetRecord>()
|
||||
const widgetEventListeners = new Set<(event: { id: string, event: Record<string, unknown> }) => void>()
|
||||
const windowContexts = new Map<string, WidgetWindowContext>()
|
||||
const iframeRequests = createWidgetIframeRequestCoordinator({
|
||||
hasWidget: id => widgetRecords.has(id),
|
||||
hasRelay: () => Boolean(eventaContext),
|
||||
emitRequest: payload => eventaContext?.emit(widgetsIframeRequestEvent, payload),
|
||||
})
|
||||
|
||||
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
|
||||
const defaultRoute = '/widgets'
|
||||
@@ -331,6 +368,7 @@ export function setupWidgetsWindowManager(params: {
|
||||
pendingRoute = undefined
|
||||
|
||||
window.on('closed', () => {
|
||||
iframeRequests.rejectAllPendingWidgetIframeRequests()
|
||||
eventaContext = undefined
|
||||
currentRoute = undefined
|
||||
if (activeWidgetsWindow === window)
|
||||
@@ -397,6 +435,7 @@ export function setupWidgetsWindowManager(params: {
|
||||
|
||||
widgetRecords.delete(id)
|
||||
windowContexts.delete(id)
|
||||
iframeRequests.rejectPendingWidgetIframeRequests(id)
|
||||
|
||||
if (emitEvent) {
|
||||
eventaContext?.emit(widgetsRemoveEvent, { id })
|
||||
@@ -684,6 +723,18 @@ export function setupWidgetsWindowManager(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function requestWidgetIframe<TResponse extends Record<string, unknown> = Record<string, unknown>>(
|
||||
id: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number },
|
||||
) {
|
||||
return iframeRequests.requestWidgetIframe<TResponse>(id, payload, options)
|
||||
}
|
||||
|
||||
function publishWidgetIframeRequestResult(result: WidgetsIframeRequestResultPayload) {
|
||||
iframeRequests.publishWidgetIframeRequestResult(result)
|
||||
}
|
||||
|
||||
async function hideWindow(params?: { id?: string }) {
|
||||
const id = params?.id
|
||||
const context = id ? windowContexts.get(id) : undefined
|
||||
@@ -703,6 +754,8 @@ export function setupWidgetsWindowManager(params: {
|
||||
getWidgetSnapshot,
|
||||
publishWidgetEvent,
|
||||
onWidgetEvent,
|
||||
requestWidgetIframe,
|
||||
publishWidgetIframeRequestResult,
|
||||
prepareWidgetWindow,
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const DOUBLE_ENTER_INTERVAL_MS = 300
|
||||
const TRAILING_NEWLINES_REGEX = /[\r\n]+$/
|
||||
const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const
|
||||
type SendMode = (typeof SEND_MODES)[number]
|
||||
type ToolCallRerunToolset = 'widgets' | 'artistry'
|
||||
const sendMode = useLocalStorage<SendMode>('ui/chat/settings/send-mode', 'enter')
|
||||
const toolCallRenderers = {
|
||||
image_journal: JournalToolCallBlock,
|
||||
@@ -226,6 +227,31 @@ async function handleRetryMessage(index: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function resolveToolCallRerunToolset(toolName: string): ToolCallRerunToolset | undefined {
|
||||
// TODO: Stop hardcoding tool names to app-local toolsets. Tool registration
|
||||
// should expose the owning runtime/toolset id so reruns can reuse the exact
|
||||
// source that created the original tool call.
|
||||
if (toolName === 'image_journal' || toolName === 'text_journal')
|
||||
return 'artistry'
|
||||
|
||||
if (toolName === 'stage_widgets' || toolName === 'get_weather')
|
||||
return 'widgets'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function handleToolCallRerun(payload: { message: ChatHistoryItem, index: number, key: string | number, toolCallId: string, toolName: string, args: string }) {
|
||||
await chatSyncStore.requestToolCallRerun({
|
||||
sessionId: chatSession.activeSessionId,
|
||||
messageId: payload.message.id,
|
||||
index: payload.index,
|
||||
toolset: resolveToolCallRerunToolset(payload.toolName),
|
||||
toolCallId: payload.toolCallId,
|
||||
toolName: payload.toolName,
|
||||
args: payload.args,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCleanupMessages() {
|
||||
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
||||
await chatSyncStore.requestCleanup()
|
||||
@@ -246,6 +272,7 @@ async function handleCleanupMessages() {
|
||||
:tool-call-renderers="toolCallRenderers"
|
||||
@delete-message="handleDeleteMessage($event.index)"
|
||||
@retry-message="handleRetryMessage($event.index)"
|
||||
@tool-call-rerun="handleToolCallRerun"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
|
||||
import type { WidgetsIframeRequestPayload, WidgetsIframeRequestResultPayload, WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
|
||||
|
||||
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { widgetsClearEvent, widgetsFetch, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa'
|
||||
import { widgetsClearEvent, widgetsFetch, widgetsIframeRequestEvent, widgetsIframeRequestResultEvent, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -41,6 +41,8 @@ const removeWidgetInvoke = useElectronEventaInvoke(widgetsRemove)
|
||||
const fetchWidget = useElectronEventaInvoke(widgetsFetch)
|
||||
const updateWidgetInvoke = useElectronEventaInvoke(widgetsUpdate)
|
||||
const pinUpdating = shallowRef(false)
|
||||
const pendingIframeRequests = shallowRef<WidgetsIframeRequestPayload[]>([])
|
||||
const eventDisposers: Array<() => void> = []
|
||||
|
||||
let ttlTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -101,6 +103,7 @@ async function requestSnapshot(id: string) {
|
||||
watch(widgetId, (id) => {
|
||||
clearTtl()
|
||||
widget.value = null
|
||||
pendingIframeRequests.value = []
|
||||
loading.value = false
|
||||
if (!id)
|
||||
return
|
||||
@@ -109,17 +112,30 @@ watch(widgetId, (id) => {
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
context.value.on(widgetsRenderEvent, (evt) => {
|
||||
eventDisposers.push(context.value.on(widgetsIframeRequestEvent, (evt) => {
|
||||
const body = evt?.body
|
||||
if (!body || body.id !== widgetId.value)
|
||||
return
|
||||
applySnapshot(body)
|
||||
})
|
||||
pendingIframeRequests.value = [
|
||||
...pendingIframeRequests.value,
|
||||
body,
|
||||
]
|
||||
}))
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
context.value.on(widgetsUpdateEvent, (evt) => {
|
||||
eventDisposers.push(context.value.on(widgetsRenderEvent, (evt) => {
|
||||
const body = evt?.body
|
||||
if (!body || body.id !== widgetId.value)
|
||||
return
|
||||
applySnapshot(body)
|
||||
}))
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
eventDisposers.push(context.value.on(widgetsUpdateEvent, (evt) => {
|
||||
const body = evt?.body
|
||||
if (!body || body.id !== widgetId.value)
|
||||
return
|
||||
@@ -137,33 +153,38 @@ onMounted(() => {
|
||||
windowSize: body.windowSize ?? widget.value.windowSize,
|
||||
ttlMs: body.ttlMs ?? widget.value.ttlMs,
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
context.value.on(widgetsRemoveEvent, (evt) => {
|
||||
eventDisposers.push(context.value.on(widgetsRemoveEvent, (evt) => {
|
||||
const body = evt?.body
|
||||
if (!body || body.id !== widgetId.value)
|
||||
return
|
||||
clearTtl()
|
||||
widget.value = null
|
||||
pendingIframeRequests.value = []
|
||||
loading.value = false
|
||||
})
|
||||
}))
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
context.value.on(widgetsClearEvent, () => {
|
||||
eventDisposers.push(context.value.on(widgetsClearEvent, () => {
|
||||
clearTtl()
|
||||
widget.value = null
|
||||
pendingIframeRequests.value = []
|
||||
loading.value = false
|
||||
})
|
||||
}))
|
||||
}
|
||||
catch {}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const dispose of eventDisposers.splice(0)) {
|
||||
dispose()
|
||||
}
|
||||
clearTtl()
|
||||
})
|
||||
|
||||
@@ -237,6 +258,11 @@ async function toggleAlwaysOnTop() {
|
||||
pinUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleIframeRequestResult(result: WidgetsIframeRequestResultPayload) {
|
||||
pendingIframeRequests.value = pendingIframeRequests.value.filter(request => request.requestId !== result.requestId)
|
||||
context.value.emit(widgetsIframeRequestResultEvent, result)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -300,7 +326,9 @@ async function toggleAlwaysOnTop() {
|
||||
:title="widget.componentName"
|
||||
:model-value="widget.componentProps"
|
||||
:size="widget.size"
|
||||
:pending-iframe-requests="pendingIframeRequests"
|
||||
v-bind="widget.componentProps"
|
||||
@iframe-request-result="handleIframeRequestResult"
|
||||
/>
|
||||
</div>
|
||||
<div v-else :class="['h-full flex items-center justify-center']">
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const mockResolveLlmTools = vi.hoisted(() => vi.fn<(options?: { customTools?: (() => Promise<Tool[]>) | Tool[] }) => Promise<Tool[]>>())
|
||||
const mockWidgetsTools = vi.hoisted(() => vi.fn<() => Promise<Tool[]>>(async () => []))
|
||||
const mockWeatherTools = vi.hoisted(() => vi.fn<() => Promise<Tool[]>>(async () => []))
|
||||
const mockImageJournalTools = vi.hoisted(() => vi.fn<() => Promise<Tool[]>>(async () => []))
|
||||
|
||||
interface MockBroadcastMessageEvent<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
type MockListener = (event: MockBroadcastMessageEvent<unknown>) => void
|
||||
interface MockChatMessage {
|
||||
id?: string
|
||||
role: string
|
||||
content: string
|
||||
slices?: Array<{ type: string, text?: string }>
|
||||
tool_results?: unknown[]
|
||||
slices?: unknown[]
|
||||
tool_results?: Array<{ id: string, isError?: boolean, result: unknown }>
|
||||
}
|
||||
|
||||
class MockBroadcastChannel {
|
||||
@@ -152,16 +159,25 @@ vi.mock('@proj-airi/stage-ui/stores/modules/consciousness', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/llm-tool-resolver', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@proj-airi/stage-ui/stores/llm-tool-resolver')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
resolveLlmTools: mockResolveLlmTools,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./tools/builtin/widgets', () => ({
|
||||
widgetsTools: vi.fn(async () => []),
|
||||
widgetsTools: mockWidgetsTools,
|
||||
}))
|
||||
|
||||
vi.mock('./tools/builtin/weather', () => ({
|
||||
weatherTools: vi.fn(async () => []),
|
||||
weatherTools: mockWeatherTools,
|
||||
}))
|
||||
|
||||
vi.mock('./tools/builtin/image-journal', () => ({
|
||||
imageJournalTools: vi.fn(async () => []),
|
||||
imageJournalTools: mockImageJournalTools,
|
||||
}))
|
||||
|
||||
describe('useChatSyncStore', async () => {
|
||||
@@ -208,6 +224,15 @@ describe('useChatSyncStore', async () => {
|
||||
throw new Error('Remote sent 403 response: {"error":{"message":"This model is not available in your region.","code":403}}')
|
||||
})
|
||||
|
||||
mockResolveLlmTools.mockReset()
|
||||
mockResolveLlmTools.mockResolvedValue([])
|
||||
mockWidgetsTools.mockReset()
|
||||
mockWidgetsTools.mockResolvedValue([])
|
||||
mockWeatherTools.mockReset()
|
||||
mockWeatherTools.mockResolvedValue([])
|
||||
mockImageJournalTools.mockReset()
|
||||
mockImageJournalTools.mockResolvedValue([])
|
||||
|
||||
mockState = {
|
||||
activeSessionId,
|
||||
sessionMessages,
|
||||
@@ -461,4 +486,123 @@ describe('useChatSyncStore', async () => {
|
||||
store.dispose()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reruns a tool call locally when this window is the authority', async () => {
|
||||
const execute = vi.fn<Tool['execute']>(async () => 'fresh result')
|
||||
const demoTool: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'demo-tool',
|
||||
description: 'Demo tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
execute,
|
||||
}
|
||||
mockWidgetsTools.mockResolvedValueOnce([demoTool])
|
||||
mockResolveLlmTools.mockImplementationOnce(async (options) => {
|
||||
if (typeof options?.customTools === 'function')
|
||||
return options.customTools()
|
||||
|
||||
return options?.customTools ?? []
|
||||
})
|
||||
const initialMessages: MockChatMessage[] = [
|
||||
{ role: 'user', content: 'run the tool', id: 'user-1' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
id: 'assistant-1',
|
||||
slices: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCall: {
|
||||
toolCallId: 'call-demo',
|
||||
toolCallType: 'function',
|
||||
toolName: 'demo-tool',
|
||||
args: '{ "value": 1 }',
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_results: [
|
||||
{
|
||||
id: 'call-demo',
|
||||
result: 'stale result',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
mockState.sessionMessages.value['session-1'] = initialMessages
|
||||
|
||||
const store = useChatSyncStore()
|
||||
store.initialize('authority')
|
||||
|
||||
await store.requestToolCallRerun({
|
||||
sessionId: 'session-1',
|
||||
messageId: 'assistant-1',
|
||||
toolset: 'widgets',
|
||||
toolCallId: 'call-demo',
|
||||
toolName: 'demo-tool',
|
||||
args: '{ "value": 2 }',
|
||||
})
|
||||
|
||||
expect(mockResolveLlmTools).toHaveBeenCalledWith({ customTools: expect.any(Function) })
|
||||
expect(mockWidgetsTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockWeatherTools).toHaveBeenCalledTimes(1)
|
||||
expect(execute).toHaveBeenCalledWith({ value: 2 }, {
|
||||
toolCallId: 'call-demo',
|
||||
messages: initialMessages,
|
||||
})
|
||||
expect(mockState.setSessionMessages).toHaveBeenCalledWith('session-1', [
|
||||
initialMessages[0],
|
||||
expect.objectContaining({
|
||||
id: 'assistant-1',
|
||||
tool_results: [
|
||||
{
|
||||
id: 'call-demo',
|
||||
result: 'fresh result',
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
store.dispose()
|
||||
})
|
||||
|
||||
it('sends tool call rerun commands from followers', async () => {
|
||||
const store = useChatSyncStore()
|
||||
store.initialize('follower')
|
||||
|
||||
const pending = store.requestToolCallRerun({
|
||||
sessionId: 'session-1',
|
||||
messageId: 'assistant-1',
|
||||
toolset: 'artistry',
|
||||
toolCallId: 'call-demo',
|
||||
toolName: 'demo-tool',
|
||||
args: '{ "value": 2 }',
|
||||
})
|
||||
pending.catch(() => {})
|
||||
|
||||
const rerunCommands = postedMessagesOfType('command')
|
||||
.filter(message => message.command === 'tool-call-rerun')
|
||||
|
||||
expect(rerunCommands).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'command',
|
||||
command: 'tool-call-rerun',
|
||||
payload: {
|
||||
sessionId: 'session-1',
|
||||
messageId: 'assistant-1',
|
||||
toolset: 'artistry',
|
||||
toolCallId: 'call-demo',
|
||||
toolName: 'demo-tool',
|
||||
args: '{ "value": 2 }',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
store.dispose()
|
||||
await expect(pending).rejects.toThrow('Chat sync channel disposed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WebSocketEventInputs } from '@proj-airi/server-sdk'
|
||||
import type { ToolCallRerunPayload } from '@proj-airi/stage-ui/stores/tool-call-rerun'
|
||||
import type { ChatHistoryItem, StreamingAssistantMessage } from '@proj-airi/stage-ui/types/chat'
|
||||
import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
@@ -10,8 +11,10 @@ import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/llm-tool-resolver'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { executeToolCallRerun } from '@proj-airi/stage-ui/stores/tool-call-rerun'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
@@ -82,6 +85,7 @@ type ChatSyncMessage
|
||||
| ChatCommandMessage<'ingest', IngestCommandPayload>
|
||||
| ChatCommandMessage<'spotlight-ingest', SpotlightIngestPayload>
|
||||
| ChatCommandMessage<'retry', RetryCommandPayload>
|
||||
| ChatCommandMessage<'tool-call-rerun', ToolCallRerunPayload<ToolsetId>>
|
||||
| ChatCommandMessage<'cleanup', { sessionId?: string }>
|
||||
| ChatCommandMessage<'delete-message', { sessionId?: string, messageId?: string, index?: number }>
|
||||
| ({ type: 'response', requestId: string, authorityId: string } & ChatResponsePayload)
|
||||
@@ -384,6 +388,16 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
|
||||
})
|
||||
}
|
||||
|
||||
async function executeToolCallRerunCommand(payload: ToolCallRerunPayload<ToolsetId>) {
|
||||
const sessionId = payload.sessionId || activeSessionId.value
|
||||
const nextMessages = await executeToolCallRerun({
|
||||
messages: chatSession.getSessionMessages(sessionId),
|
||||
payload,
|
||||
resolveTools: () => resolveLlmTools({ customTools: resolveTools(payload.toolset) }),
|
||||
})
|
||||
chatSession.setSessionMessages(sessionId, nextMessages)
|
||||
}
|
||||
|
||||
function executeDeleteMessage(payload: { sessionId?: string, messageId?: string, index?: number }) {
|
||||
const sessionId = payload.sessionId || activeSessionId.value
|
||||
const nextMessages = chatSession.getSessionMessages(sessionId).filter((message, index) => {
|
||||
@@ -444,6 +458,9 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
|
||||
case 'retry':
|
||||
await executeRetry(message.payload)
|
||||
break
|
||||
case 'tool-call-rerun':
|
||||
await executeToolCallRerunCommand(message.payload)
|
||||
break
|
||||
case 'cleanup':
|
||||
cleanupMessages(message.payload.sessionId)
|
||||
break
|
||||
@@ -641,6 +658,21 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
|
||||
})
|
||||
}
|
||||
|
||||
async function requestToolCallRerun(payload: ToolCallRerunPayload<ToolsetId>) {
|
||||
if (mode.value === 'authority') {
|
||||
await executeToolCallRerunCommand(payload)
|
||||
return
|
||||
}
|
||||
|
||||
return await dispatch<void>({
|
||||
type: 'command',
|
||||
requestId: createRequestId(),
|
||||
senderId: instanceId,
|
||||
command: 'tool-call-rerun',
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
async function requestCleanup(sessionId?: string) {
|
||||
if (mode.value === 'authority') {
|
||||
cleanupMessages(sessionId)
|
||||
@@ -688,6 +720,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
|
||||
requestIngest,
|
||||
requestSpotlightIngest,
|
||||
requestRetry,
|
||||
requestToolCallRerun,
|
||||
requestCleanup,
|
||||
requestDeleteMessage,
|
||||
}
|
||||
|
||||
+29
-2
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import type {
|
||||
WidgetsIframeRequestPayload,
|
||||
WidgetsIframeRequestResultPayload,
|
||||
} from '../../../../shared/eventa'
|
||||
import type { PluginHostModuleSummary, PluginModuleWidgetPayload } from '../../../../shared/eventa/plugin/host'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { widgetsIframePublish } from '../../../../shared/eventa'
|
||||
import { electronPluginGetAssetBaseUrl } from '../../../../shared/eventa/plugin/assets'
|
||||
@@ -15,6 +19,10 @@ import { publishWidgetSparkNotifyReaction } from '../composables/use-bridge-spar
|
||||
import { useExtensionUIForModule } from '../composables/use-extension-ui-for-module'
|
||||
import { useIframeMessagePort } from '../composables/use-iframe-message-port'
|
||||
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../host'
|
||||
import {
|
||||
createExtensionUiIframeRequestHandler,
|
||||
createExtensionUiIframeRequestQueueProcessor,
|
||||
} from './iframe-request'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
@@ -22,14 +30,20 @@ const props = withDefaults(defineProps<{
|
||||
moduleId?: string
|
||||
componentProps?: Record<string, any>
|
||||
payload?: Record<string, any>
|
||||
pendingIframeRequests?: WidgetsIframeRequestPayload[]
|
||||
}>(), {
|
||||
title: 'Extension UI',
|
||||
modelValue: () => ({}),
|
||||
moduleId: undefined,
|
||||
componentProps: undefined,
|
||||
payload: undefined,
|
||||
pendingIframeRequests: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
iframeRequestResult: [result: WidgetsIframeRequestResultPayload]
|
||||
}>()
|
||||
|
||||
function firstString(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -96,7 +110,7 @@ const iframeSandbox = computed(() => firstString(
|
||||
|
||||
const iframeElement = shallowRef<HTMLIFrameElement | null>(null)
|
||||
|
||||
const { context: iframeContext, iframeLoadError, onIframeError, onIframeLoad } = useIframeMessagePort(
|
||||
const { context: iframeContext, iframeReady, iframeLoadError, onIframeError, onIframeLoad } = useIframeMessagePort(
|
||||
iframeElement,
|
||||
{
|
||||
moduleId,
|
||||
@@ -124,6 +138,19 @@ const { context: iframeContext, iframeLoadError, onIframeError, onIframeLoad } =
|
||||
},
|
||||
},
|
||||
)
|
||||
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
|
||||
getContext: () => iframeContext,
|
||||
})
|
||||
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({
|
||||
shouldHandle: request => request.id === moduleId.value,
|
||||
isReady: () => iframeReady.value,
|
||||
requestWidgetIframe,
|
||||
emitResult: result => emit('iframeRequestResult', result),
|
||||
})
|
||||
|
||||
watch([() => props.pendingIframeRequests, iframeReady], ([requests]) => {
|
||||
processIframeRequests(requests)
|
||||
}, { immediate: true })
|
||||
|
||||
function setIframeElement(element: Element | ComponentPublicInstance | null) {
|
||||
iframeElement.value = element instanceof HTMLIFrameElement ? element : null
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { createContext, defineInvokeHandler } from '@moeru/eventa'
|
||||
import { gameletIframeRequest } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createExtensionUiIframeRequestHandler,
|
||||
createExtensionUiIframeRequestQueueProcessor,
|
||||
} from './iframe-request'
|
||||
|
||||
describe('createExtensionUiIframeRequestHandler', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('invokes the mounted iframe context and returns the gamelet response', async () => {
|
||||
const iframeContext = createContext()
|
||||
defineInvokeHandler(iframeContext, gameletIframeRequest, ({ payload }) => ({
|
||||
fen: `fen:${payload.action}`,
|
||||
}))
|
||||
|
||||
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
|
||||
getContext: () => iframeContext,
|
||||
})
|
||||
|
||||
await expect(requestWidgetIframe({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
payload: { action: 'snapshot' },
|
||||
timeoutMs: 1000,
|
||||
})).resolves.toEqual({
|
||||
fen: 'fen:snapshot',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when the iframe context is not ready', async () => {
|
||||
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
|
||||
getContext: () => undefined,
|
||||
})
|
||||
|
||||
await expect(requestWidgetIframe({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
payload: { action: 'snapshot' },
|
||||
timeoutMs: 1000,
|
||||
})).rejects.toThrow('Gamelet `kit-module:board` iframe context is not ready.')
|
||||
})
|
||||
|
||||
it('aborts the iframe invoke when the request timeout elapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
const iframeContext = createContext()
|
||||
defineInvokeHandler(iframeContext, gameletIframeRequest, async () => {
|
||||
await new Promise(() => {})
|
||||
return {}
|
||||
})
|
||||
|
||||
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
|
||||
getContext: () => iframeContext,
|
||||
})
|
||||
|
||||
const request = requestWidgetIframe({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
payload: { action: 'snapshot' },
|
||||
timeoutMs: 5,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
|
||||
await expect(request).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createExtensionUiIframeRequestQueueProcessor', () => {
|
||||
it('keeps iframe requests pending until the iframe ready handshake arrives', async () => {
|
||||
let iframeReady = false
|
||||
const emitResult = vi.fn()
|
||||
const requestWidgetIframe = vi.fn(async request => ({
|
||||
fen: `fen:${request.requestId}`,
|
||||
}))
|
||||
const input = {
|
||||
shouldHandle: (request: { id: string }) => request.id === 'kit-module:board',
|
||||
isReady: () => iframeReady,
|
||||
requestWidgetIframe,
|
||||
emitResult,
|
||||
}
|
||||
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor(input)
|
||||
|
||||
const requests = [{
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
payload: { action: 'start' },
|
||||
timeoutMs: 1000,
|
||||
}]
|
||||
|
||||
processIframeRequests(requests)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(requestWidgetIframe).not.toHaveBeenCalled()
|
||||
expect(emitResult).not.toHaveBeenCalled()
|
||||
|
||||
iframeReady = true
|
||||
processIframeRequests(requests)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(requestWidgetIframe).toHaveBeenCalledOnce()
|
||||
expect(emitResult).toHaveBeenCalledWith({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen:req-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('emits results for every queued request delivered in one Vue update', async () => {
|
||||
const emitResult = vi.fn()
|
||||
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({
|
||||
shouldHandle: request => request.id === 'kit-module:board',
|
||||
requestWidgetIframe: async request => ({
|
||||
fen: `fen:${request.requestId}`,
|
||||
}),
|
||||
emitResult,
|
||||
})
|
||||
|
||||
processIframeRequests([
|
||||
{
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
payload: { action: 'snapshot' },
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-2',
|
||||
payload: { action: 'snapshot' },
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
])
|
||||
await Promise.resolve()
|
||||
|
||||
expect(emitResult).toHaveBeenCalledWith({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen:req-1' },
|
||||
})
|
||||
expect(emitResult).toHaveBeenCalledWith({
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-2',
|
||||
ok: true,
|
||||
result: { fen: 'fen:req-2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not process the same request id twice', async () => {
|
||||
const emitResult = vi.fn()
|
||||
const requestWidgetIframe = vi.fn(async () => ({ fen: 'fen-once' }))
|
||||
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({
|
||||
shouldHandle: () => true,
|
||||
requestWidgetIframe,
|
||||
emitResult,
|
||||
})
|
||||
|
||||
const requests = [{
|
||||
id: 'kit-module:board',
|
||||
requestId: 'req-1',
|
||||
payload: { action: 'snapshot' },
|
||||
timeoutMs: 1000,
|
||||
}]
|
||||
processIframeRequests(requests)
|
||||
processIframeRequests(requests)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(requestWidgetIframe).toHaveBeenCalledOnce()
|
||||
expect(emitResult).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { EventContext } from '@moeru/eventa'
|
||||
import type { GameletIframeResponsePayload } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
|
||||
|
||||
import type {
|
||||
WidgetsIframeRequestPayload,
|
||||
WidgetsIframeRequestResultPayload,
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { gameletIframeRequest } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
|
||||
|
||||
export interface ExtensionUiIframeRequestHandlerInput {
|
||||
getContext: () => EventContext<any, any> | undefined
|
||||
}
|
||||
|
||||
export interface ExtensionUiIframeRequestQueueProcessorInput {
|
||||
/** Returns whether this mounted iframe owns the request. */
|
||||
shouldHandle: (request: WidgetsIframeRequestPayload) => boolean
|
||||
/** Returns whether the iframe has announced its invoke handlers are ready. */
|
||||
isReady?: () => boolean
|
||||
/** Invokes the mounted iframe and returns its response record. */
|
||||
requestWidgetIframe: (request: WidgetsIframeRequestPayload) => Promise<GameletIframeResponsePayload>
|
||||
/** Emits one correlated request result back to the widget host. */
|
||||
emitResult: (result: WidgetsIframeRequestResultPayload) => void
|
||||
}
|
||||
|
||||
function createTimeoutSignal(timeoutMs: number): { signal: AbortSignal, cleanup: () => void } {
|
||||
const timeout = (AbortSignal as typeof AbortSignal & {
|
||||
timeout?: (milliseconds: number) => AbortSignal
|
||||
}).timeout
|
||||
|
||||
if (timeout) {
|
||||
return {
|
||||
signal: timeout(timeoutMs),
|
||||
cleanup: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(new DOMException('The operation timed out.', 'TimeoutError'))
|
||||
}, timeoutMs)
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => clearTimeout(timer),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the renderer-side relay that invokes one mounted extension iframe.
|
||||
*
|
||||
* The widgets renderer receives host requests from Electron main, then this
|
||||
* helper forwards the request into the iframe Eventa context with the shared
|
||||
* gamelet invoke contract and caller-provided timeout budget.
|
||||
*/
|
||||
export function createExtensionUiIframeRequestHandler(input: ExtensionUiIframeRequestHandlerInput) {
|
||||
return async function requestWidgetIframe(request: WidgetsIframeRequestPayload) {
|
||||
const context = input.getContext()
|
||||
if (!context) {
|
||||
throw new Error(`Gamelet \`${request.id}\` iframe context is not ready.`)
|
||||
}
|
||||
|
||||
const invokeGameletIframeRequest = defineInvoke(context, gameletIframeRequest)
|
||||
const timeoutSignal = createTimeoutSignal(request.timeoutMs)
|
||||
|
||||
try {
|
||||
return await invokeGameletIframeRequest({
|
||||
requestId: request.requestId,
|
||||
payload: request.payload,
|
||||
}, {
|
||||
signal: timeoutSignal.signal,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
timeoutSignal.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a queue processor for iframe requests delivered through Vue props.
|
||||
*
|
||||
* Vue batches parent-to-child prop updates, so iframe requests must be modeled as
|
||||
* a queue instead of a single latest value. This processor deduplicates by
|
||||
* `requestId` and emits one correlated result for every unhandled request.
|
||||
*/
|
||||
export function createExtensionUiIframeRequestQueueProcessor(input: ExtensionUiIframeRequestQueueProcessorInput) {
|
||||
const handledRequestIds = new Set<string>()
|
||||
|
||||
return function processIframeRequests(requests: readonly WidgetsIframeRequestPayload[] | undefined) {
|
||||
if (input.isReady && !input.isReady()) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const request of requests ?? []) {
|
||||
if (!input.shouldHandle(request) || handledRequestIds.has(request.requestId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
handledRequestIds.add(request.requestId)
|
||||
void input.requestWidgetIframe(request)
|
||||
.then((result) => {
|
||||
input.emitResult({
|
||||
id: request.id,
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
result,
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
input.emitResult({
|
||||
id: request.id,
|
||||
requestId: request.requestId,
|
||||
ok: false,
|
||||
error: errorMessageFrom(error) ?? 'Gamelet request failed.',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -102,6 +102,7 @@ export function useIframeMessagePort(
|
||||
},
|
||||
) {
|
||||
const iframeLoadError = shallowRef<string>()
|
||||
const iframeReady = shallowRef(false)
|
||||
|
||||
const iframeRuntime = createContext({
|
||||
channel: widgetsIframeChannel,
|
||||
@@ -142,15 +143,24 @@ export function useIframeMessagePort(
|
||||
}
|
||||
|
||||
function onIframeLoad() {
|
||||
// NOTICE:
|
||||
// A host component can mount after an already-loaded iframe during dev HMR
|
||||
// or renderer remounts, which means the iframe's one-shot ready event may
|
||||
// have already been emitted. Treat load as the point where invokes may be
|
||||
// attempted; Eventa still owns request timeout/error handling if the iframe
|
||||
// has not registered its handler yet.
|
||||
iframeReady.value = true
|
||||
iframeLoadError.value = undefined
|
||||
emitInitPayload()
|
||||
}
|
||||
|
||||
function onIframeError() {
|
||||
iframeReady.value = false
|
||||
iframeLoadError.value = 'Failed to load extension UI iframe source.'
|
||||
}
|
||||
|
||||
iframeRuntime.context.on(widgetsIframeReadyEvent, () => {
|
||||
iframeReady.value = true
|
||||
emitInitPayload()
|
||||
})
|
||||
|
||||
@@ -163,6 +173,7 @@ export function useIframeMessagePort(
|
||||
})
|
||||
|
||||
watch(options.moduleId, () => {
|
||||
iframeReady.value = false
|
||||
emitInitPayload()
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -180,6 +191,7 @@ export function useIframeMessagePort(
|
||||
|
||||
return {
|
||||
context: iframeRuntime.context,
|
||||
iframeReady,
|
||||
iframeLoadError,
|
||||
onIframeLoad,
|
||||
onIframeError,
|
||||
|
||||
+10
-79
@@ -1,4 +1,6 @@
|
||||
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/window-message'
|
||||
import { gameletIframeRequest } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
|
||||
import { widgetsIframeInitEvent, widgetsIframePublishEvent } from '@proj-airi/plugin-sdk-tamagotchi/widgets'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -128,12 +130,7 @@ describe('createContext', () => {
|
||||
iframe.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(initPayload.props.request.responseRoute).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' })
|
||||
* expect(publishedPayload.route).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' })
|
||||
*/
|
||||
it('relays gamelet request props and iframe response envelopes over the extension UI bridge', async () => {
|
||||
it('relays gamelet iframe invoke requests over the extension UI bridge', async () => {
|
||||
const parentWindow = new MockWindow()
|
||||
const iframeWindow = new MockWindow()
|
||||
parentWindow.peer = iframeWindow
|
||||
@@ -152,85 +149,19 @@ describe('createContext', () => {
|
||||
targetWindow: () => parentWindow as unknown as Window,
|
||||
})
|
||||
|
||||
const initPayload = new Promise<Record<string, unknown>>((resolve) => {
|
||||
iframe.context.on(widgetsIframeInitEvent, (event) => {
|
||||
const request = event.body?.props?.request
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
return
|
||||
}
|
||||
|
||||
resolve(request as Record<string, unknown>)
|
||||
})
|
||||
defineInvokeHandler(iframe.context, gameletIframeRequest, ({ payload }) => {
|
||||
return {
|
||||
fen: payload.action === 'snapshot' ? 'fen-after-request' : 'unknown',
|
||||
}
|
||||
})
|
||||
|
||||
host.context.emit(widgetsIframeInitEvent, {
|
||||
moduleId: 'chess:board',
|
||||
config: {},
|
||||
module: undefined,
|
||||
props: {
|
||||
request: {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'request',
|
||||
},
|
||||
responseRoute: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
requestId: 'req-1',
|
||||
payload: {
|
||||
action: 'snapshot',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(initPayload).resolves.toEqual({
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'request',
|
||||
},
|
||||
responseRoute: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
const invokeGameletIframeRequest = defineInvoke(host.context, gameletIframeRequest)
|
||||
await expect(invokeGameletIframeRequest({
|
||||
requestId: 'req-1',
|
||||
payload: {
|
||||
action: 'snapshot',
|
||||
},
|
||||
})
|
||||
|
||||
const publishedPayload = new Promise<Record<string, unknown>>((resolve) => {
|
||||
host.context.on(widgetsIframePublishEvent, (event) => {
|
||||
if (!event.body) {
|
||||
return
|
||||
}
|
||||
|
||||
resolve(event.body)
|
||||
})
|
||||
})
|
||||
|
||||
iframe.context.emit(widgetsIframePublishEvent, {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
fen: 'fen-after-request',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(publishedPayload).resolves.toEqual({
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
fen: 'fen-after-request',
|
||||
},
|
||||
})
|
||||
})).resolves.toEqual({ fen: 'fen-after-request' })
|
||||
|
||||
host.dispose()
|
||||
iframe.dispose()
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Locale } from '@intlify/core'
|
||||
import type {
|
||||
GameletIframeRequestPayload as GameletIframeInvokePayload,
|
||||
GameletIframeResponsePayload,
|
||||
} from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
|
||||
import type { ServerOptions } from '@proj-airi/server-runtime/server'
|
||||
import type {
|
||||
ShortcutAccelerator,
|
||||
@@ -144,6 +148,57 @@ export interface WidgetSnapshot {
|
||||
ttlMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Request relayed from Electron main to one mounted widget iframe through the widgets renderer.
|
||||
*/
|
||||
export interface WidgetsIframeRequestPayload {
|
||||
/** Widget id that identifies the mounted iframe target. */
|
||||
id: string
|
||||
/** Relay correlation id echoed by the renderer-to-main result event. */
|
||||
requestId: string
|
||||
/** Structured-clone-safe request record forwarded into the iframe Eventa runtime. */
|
||||
payload: GameletIframeInvokePayload['payload']
|
||||
/** Request timeout budget in milliseconds. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared fields for a renderer-to-main iframe request result.
|
||||
*/
|
||||
export interface WidgetsIframeRequestResultBasePayload {
|
||||
/** Widget id that produced the result. */
|
||||
id: string
|
||||
/** Relay correlation id matching the original main-to-renderer request. */
|
||||
requestId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful renderer-to-main iframe request result.
|
||||
*/
|
||||
export interface WidgetsIframeRequestSuccessPayload extends WidgetsIframeRequestResultBasePayload {
|
||||
/** Marks this result as a successful iframe response. */
|
||||
ok: true
|
||||
/** Structured-clone-safe response record returned by the iframe Eventa runtime. */
|
||||
result: GameletIframeResponsePayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed renderer-to-main iframe request result.
|
||||
*/
|
||||
export interface WidgetsIframeRequestFailurePayload extends WidgetsIframeRequestResultBasePayload {
|
||||
/** Marks this result as a failed iframe response. */
|
||||
ok: false
|
||||
/** Error message returned when the iframe request fails. */
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result relayed from the widgets renderer back to Electron main for one iframe request.
|
||||
*/
|
||||
export type WidgetsIframeRequestResultPayload
|
||||
= | WidgetsIframeRequestSuccessPayload
|
||||
| WidgetsIframeRequestFailurePayload
|
||||
|
||||
export interface PluginManifestSummary {
|
||||
extensionId: string
|
||||
entrypoints: Record<string, string | undefined>
|
||||
@@ -415,6 +470,10 @@ export const widgetsRenderEvent = defineEventa<WidgetSnapshot>('eventa:event:ele
|
||||
export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:electron:windows:widgets:remove')
|
||||
export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:widgets:clear')
|
||||
export const widgetsUpdateEvent = defineEventa<WidgetsUpdatePayload>('eventa:event:electron:windows:widgets:update')
|
||||
/** Main-to-renderer event requesting work from a mounted widget iframe. */
|
||||
export const widgetsIframeRequestEvent = defineEventa<WidgetsIframeRequestPayload>('eventa:event:electron:windows:widgets:iframe-request')
|
||||
/** Renderer-to-main event carrying the correlated result for a widget iframe request. */
|
||||
export const widgetsIframeRequestResultEvent = defineEventa<WidgetsIframeRequestResultPayload>('eventa:event:electron:windows:widgets:iframe-request-result')
|
||||
|
||||
// Onboarding window events
|
||||
export const electronOnboardingClose = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:close')
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
widgetsIframeRequestEvent,
|
||||
widgetsIframeRequestResultEvent,
|
||||
} from './index'
|
||||
|
||||
describe('widgets iframe request events', () => {
|
||||
it('uses stable event ids for main renderer gamelet request relay', () => {
|
||||
expect(widgetsIframeRequestEvent.id).toBe('eventa:event:electron:windows:widgets:iframe-request')
|
||||
expect(widgetsIframeRequestResultEvent.id).toBe('eventa:event:electron:windows:widgets:iframe-request-result')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
/**
|
||||
* Request payload sent from the host gamelet runtime to one mounted iframe.
|
||||
*/
|
||||
export interface GameletIframeRequestPayload {
|
||||
/** Host-generated correlation id used outside Eventa invoke for main/renderer relay isolation. */
|
||||
requestId: string
|
||||
/** JSON-compatible command payload supplied by the extension-side gamelet client. */
|
||||
payload: HostDataRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Response payload returned by one mounted gamelet iframe.
|
||||
*/
|
||||
export type GameletIframeResponsePayload = HostDataRecord
|
||||
|
||||
/**
|
||||
* Stable invoke name shared by the host relay and mounted gamelet iframe handler.
|
||||
*/
|
||||
export const gameletIframeRequestEventName = 'eventa:invoke:gamelet:iframe:request'
|
||||
|
||||
/**
|
||||
* Shared invoke contract used by widget hosts to request work from a mounted gamelet iframe.
|
||||
*
|
||||
* The Electron main process cannot access iframe windows directly, so renderer code invokes
|
||||
* this contract on the iframe Eventa context and relays the result back to main.
|
||||
*/
|
||||
export const gameletIframeRequest = defineInvokeEventa<
|
||||
GameletIframeResponsePayload,
|
||||
GameletIframeRequestPayload
|
||||
>(gameletIframeRequestEventName)
|
||||
@@ -3,6 +3,8 @@ import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import { defineKit } from '@proj-airi/plugin-sdk'
|
||||
|
||||
export * from './events'
|
||||
|
||||
export interface GameletKitClient {
|
||||
iframe: (input: { assetPath?: string, src?: string, sandbox?: string }) => HostDataRecord
|
||||
mount: (definition: {
|
||||
|
||||
@@ -13,7 +13,13 @@ import { DisposableStore } from '@proj-airi/plugin-sdk'
|
||||
import { object, optional, string } from 'valibot'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { gameletKit, TamagotchiToolRegistry, toolKit } from './index'
|
||||
import {
|
||||
gameletIframeRequest,
|
||||
gameletIframeRequestEventName,
|
||||
gameletKit,
|
||||
TamagotchiToolRegistry,
|
||||
toolKit,
|
||||
} from './index'
|
||||
import { createGamelet } from './kits/gamelet'
|
||||
import { registerTools } from './kits/tool'
|
||||
|
||||
@@ -158,6 +164,15 @@ function createToolModuleRef(input: {
|
||||
}
|
||||
|
||||
describe('plugin-sdk-tamagotchi', () => {
|
||||
it('exports shared gamelet iframe request contracts', () => {
|
||||
expect(gameletIframeRequestEventName).toBe('eventa:invoke:gamelet:iframe:request')
|
||||
expect(gameletIframeRequest).toEqual(expect.objectContaining({
|
||||
sendEvent: expect.objectContaining({
|
||||
id: expect.stringContaining('eventa:invoke:gamelet:iframe:request'),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('exposes gameletKit as a module-scoped kit client', async () => {
|
||||
const bindings: unknown[] = []
|
||||
const client = gameletKit.createClient(createGameletRuntime({
|
||||
|
||||
@@ -14,6 +14,8 @@ import ChatActionButtons from '../Widgets/ChatActionButtons.vue'
|
||||
import ChatArea from '../Widgets/ChatArea.vue'
|
||||
import ChatContainer from '../Widgets/ChatContainer.vue'
|
||||
|
||||
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
|
||||
|
||||
const { isReady } = useDeferredMount()
|
||||
const { sending } = storeToRefs(useChatOrchestratorStore())
|
||||
const { messages } = storeToRefs(useChatSessionStore())
|
||||
@@ -22,6 +24,7 @@ const { streamingMessage } = storeToRefs(useChatStreamStore())
|
||||
const isLoading = ref(true)
|
||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||
const { trackChatMessageDeleted } = useAnalytics()
|
||||
const { rerunToolCall } = useChatToolCallRerun()
|
||||
|
||||
function handleDeleteMessage(index: number) {
|
||||
const message = messages.value[index]
|
||||
@@ -53,6 +56,7 @@ function handleDeleteMessage(index: number) {
|
||||
h-full
|
||||
variant="desktop"
|
||||
@delete-message="handleDeleteMessage($event.index)"
|
||||
@tool-call-rerun="rerunToolCall"
|
||||
@vue:mounted="isLoading = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
|
||||
import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
|
||||
import { useTranscriptions } from '../../composables/use-transcriptions'
|
||||
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
|
||||
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
|
||||
import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
@@ -41,6 +42,7 @@ const { streamingMessage } = storeToRefs(chatStream)
|
||||
const { sending } = storeToRefs(chatOrchestrator)
|
||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||
const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics()
|
||||
const { rerunToolCall } = useChatToolCallRerun()
|
||||
|
||||
function handleDeleteMessage(index: number) {
|
||||
const message = messages.value[index]
|
||||
@@ -184,6 +186,7 @@ onMounted(() => {
|
||||
'relative z-20',
|
||||
]"
|
||||
@delete-message="handleDeleteMessage($event.index)"
|
||||
@tool-call-rerun="rerunToolCall"
|
||||
/>
|
||||
</Transition>
|
||||
</KeepAlive>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/llm-tool-resolver'
|
||||
import { executeToolCallRerun } from '@proj-airi/stage-ui/stores/tool-call-rerun'
|
||||
|
||||
export interface ChatToolCallRerunEvent {
|
||||
message: ChatHistoryItem
|
||||
index: number
|
||||
key: string | number
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
args: string
|
||||
}
|
||||
|
||||
export function useChatToolCallRerun() {
|
||||
const chatSession = useChatSessionStore()
|
||||
|
||||
async function rerunToolCall(payload: ChatToolCallRerunEvent) {
|
||||
const sessionId = chatSession.activeSessionId
|
||||
const currentMessages = chatSession.getSessionMessages(sessionId)
|
||||
|
||||
try {
|
||||
const nextMessages = await executeToolCallRerun({
|
||||
messages: currentMessages,
|
||||
payload: {
|
||||
sessionId,
|
||||
messageId: payload.message.id,
|
||||
index: payload.index,
|
||||
toolCallId: payload.toolCallId,
|
||||
toolName: payload.toolName,
|
||||
args: payload.args,
|
||||
},
|
||||
resolveTools: () => resolveLlmTools(),
|
||||
})
|
||||
chatSession.setSessionMessages(sessionId, nextMessages)
|
||||
}
|
||||
catch (error) {
|
||||
chatSession.setSessionMessages(sessionId, [
|
||||
...currentMessages,
|
||||
{
|
||||
role: 'error',
|
||||
content: errorMessageFrom(error) ?? 'Failed to rerun tool call.',
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rerunToolCall,
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'toolCallRerun', payload: { toolCallId: string, toolName: string, args: string }): void
|
||||
}>()
|
||||
|
||||
const resolvedSlices = computed<ChatSlices[]>(() => {
|
||||
@@ -111,10 +112,12 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
|
||||
<component
|
||||
:is="getToolCallRenderer(slice)"
|
||||
v-if="slice.type === 'tool-call'"
|
||||
:tool-call-id="slice.toolCall.toolCallId"
|
||||
:tool-name="slice.toolCall.toolName"
|
||||
:args="slice.toolCall.args"
|
||||
:state="getToolCallState(slice)"
|
||||
:result="getToolCallResult(slice)?.result"
|
||||
@tool-call-rerun="emit('toolCallRerun', $event)"
|
||||
/>
|
||||
<template v-else-if="slice.type === 'tool-call-result'" />
|
||||
<template v-else-if="slice.type === 'text'">
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { ChatHistoryItem } from '../../../../types/chat'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
import { defineComponent, shallowRef } from 'vue'
|
||||
import { computed, defineComponent, shallowRef } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ChatHistory from './history.vue'
|
||||
|
||||
import { getChatHistoryItemKey } from '../utils'
|
||||
|
||||
vi.mock('../composables/use-chat-history-scroll', () => ({
|
||||
useChatHistoryScroll: () => undefined,
|
||||
}))
|
||||
@@ -57,15 +59,24 @@ function createHarness(messages: ChatHistoryItem[]) {
|
||||
},
|
||||
setup() {
|
||||
const lastRetryIndex = shallowRef('none')
|
||||
const lastToolCallRerunPayload = shallowRef('')
|
||||
|
||||
function handleRetryMessage(payload: { index: number }) {
|
||||
lastRetryIndex.value = String(payload.index)
|
||||
}
|
||||
|
||||
function handleToolCallRerun(payload: unknown) {
|
||||
lastToolCallRerunPayload.value = JSON.stringify(payload)
|
||||
}
|
||||
|
||||
const toolCallRerunPayload = computed(() => lastToolCallRerunPayload.value)
|
||||
|
||||
return {
|
||||
handleRetryMessage,
|
||||
handleToolCallRerun,
|
||||
lastRetryIndex,
|
||||
messages,
|
||||
toolCallRerunPayload,
|
||||
}
|
||||
},
|
||||
template: `
|
||||
@@ -73,8 +84,10 @@ function createHarness(messages: ChatHistoryItem[]) {
|
||||
<ChatHistory
|
||||
:messages="messages"
|
||||
@retry-message="handleRetryMessage"
|
||||
@tool-call-rerun="handleToolCallRerun"
|
||||
/>
|
||||
<output aria-label="retry-index">{{ lastRetryIndex }}</output>
|
||||
<output aria-label="tool-call-rerun">{{ toolCallRerunPayload }}</output>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -133,4 +146,46 @@ describe('chatHistory retry actions', () => {
|
||||
|
||||
expect(document.body.textContent).not.toContain('Retry')
|
||||
})
|
||||
|
||||
it('emits tool-call-rerun with message context when a tool call rerun button is clicked', async () => {
|
||||
const args = JSON.stringify({ location: 'Tokyo' })
|
||||
const assistantMessage: ChatHistoryItem = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
slices: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCall: {
|
||||
toolCallId: 'call-weather',
|
||||
toolCallType: 'function',
|
||||
toolName: 'weather',
|
||||
args,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_results: [],
|
||||
createdAt: 1710000000000,
|
||||
}
|
||||
const messages: ChatHistoryItem[] = [
|
||||
{ role: 'user', content: 'weather in Tokyo' },
|
||||
assistantMessage,
|
||||
]
|
||||
|
||||
const screen = await render(createHarness(messages), {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
})
|
||||
|
||||
await screen.getByLabelText('Re-run tool call').click()
|
||||
|
||||
await expect.element(screen.getByLabelText('tool-call-rerun')).toHaveTextContent(JSON.stringify({
|
||||
message: assistantMessage,
|
||||
index: 1,
|
||||
key: getChatHistoryItemKey(assistantMessage, 1),
|
||||
toolCallId: 'call-weather',
|
||||
toolName: 'weather',
|
||||
args,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ const emit = defineEmits<{
|
||||
(e: 'copyMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
|
||||
(e: 'deleteMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
|
||||
(e: 'retryMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
|
||||
(e: 'toolCallRerun', payload: { message: ChatHistoryItem, index: number, key: string | number, toolCallId: string, toolName: string, args: string }): void
|
||||
}>()
|
||||
|
||||
const chatHistoryRef = ref<HTMLDivElement>()
|
||||
@@ -100,6 +101,19 @@ function emitRetryMessage(message: ChatHistoryItem, index: number) {
|
||||
key: getChatHistoryItemKey(message, index),
|
||||
})
|
||||
}
|
||||
|
||||
function emitToolCallRerun(
|
||||
message: ChatHistoryItem,
|
||||
index: number,
|
||||
payload: { toolCallId: string, toolName: string, args: string },
|
||||
) {
|
||||
emit('toolCallRerun', {
|
||||
message,
|
||||
index,
|
||||
key: getChatHistoryItemKey(message, index),
|
||||
...payload,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,6 +145,7 @@ function emitRetryMessage(message: ChatHistoryItem, index: number) {
|
||||
:tool-call-renderers="toolCallRenderers"
|
||||
@copy="emitCopyMessage(message, index)"
|
||||
@delete="emitDeleteMessage(message, index)"
|
||||
@tool-call-rerun="emitToolCallRerun(message, index, $event)"
|
||||
/>
|
||||
<ChatUserItem
|
||||
v-else-if="message.role === 'user'"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(fileURLToPath(new URL('./tool-call-block.vue', import.meta.url)), 'utf8')
|
||||
|
||||
describe('chat tool call block rerun action', () => {
|
||||
it('wires the rerun button click to the expected toolCallRerun payload', () => {
|
||||
expect(source).toContain('toolCallId: string')
|
||||
expect(source).toContain('(e: \'toolCallRerun\', payload: { toolCallId: string, toolName: string, args: string })')
|
||||
expect(source).toContain('aria-label="Re-run tool call"')
|
||||
expect(source).toContain('@click.stop="emitToolCallRerun"')
|
||||
expect(source).toContain('i-solar:refresh-bold')
|
||||
expect(source).toContain('emit(\'toolCallRerun\', {')
|
||||
expect(source).toContain('toolCallId: props.toolCallId')
|
||||
expect(source).toContain('toolName: props.toolName')
|
||||
expect(source).toContain('args: props.args')
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,17 @@ import { computed } from 'vue'
|
||||
import { createToolResultError } from './tool-call-display'
|
||||
|
||||
const props = defineProps<{
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
args: string
|
||||
state?: 'executing' | 'done' | 'error'
|
||||
result?: unknown
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toolCallRerun', payload: { toolCallId: string, toolName: string, args: string }): void
|
||||
}>()
|
||||
|
||||
const resultError = computed(() => props.state === 'error' ? createToolResultError(props.result) : undefined)
|
||||
|
||||
const formattedArgs = computed(() => {
|
||||
@@ -22,6 +27,14 @@ const formattedArgs = computed(() => {
|
||||
return props.args
|
||||
}
|
||||
})
|
||||
|
||||
function emitToolCallRerun() {
|
||||
emit('toolCallRerun', {
|
||||
toolCallId: props.toolCallId,
|
||||
toolName: props.toolName,
|
||||
args: props.args,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,31 +45,50 @@ const formattedArgs = computed(() => {
|
||||
]"
|
||||
>
|
||||
<template #trigger="{ visible, setVisible }">
|
||||
<button
|
||||
<div
|
||||
:class="[
|
||||
'w-full text-start',
|
||||
'inline-flex items-center',
|
||||
'w-full',
|
||||
'inline-flex items-center gap-1',
|
||||
]"
|
||||
@click="setVisible(!visible)"
|
||||
>
|
||||
<div
|
||||
v-if="state === 'executing'"
|
||||
i-eos-icons:loading class="mr-1 inline-block op-50"
|
||||
/>
|
||||
<div
|
||||
v-else-if="state === 'error'"
|
||||
i-solar:danger-circle-bold-duotone class="mr-1 inline-block text-red-500"
|
||||
/>
|
||||
<div
|
||||
v-else-if="state === 'done'"
|
||||
i-solar:check-circle-bold-duotone class="mr-1 inline-block text-emerald-500"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
|
||||
/>
|
||||
<code class="text-xs">{{ toolName }}</code>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'min-w-0 flex-1 text-start',
|
||||
'inline-flex items-center',
|
||||
]"
|
||||
@click="setVisible(!visible)"
|
||||
>
|
||||
<div
|
||||
v-if="state === 'executing'"
|
||||
i-eos-icons:loading class="mr-1 inline-block op-50"
|
||||
/>
|
||||
<div
|
||||
v-else-if="state === 'error'"
|
||||
i-solar:danger-circle-bold-duotone class="mr-1 inline-block text-red-500"
|
||||
/>
|
||||
<div
|
||||
v-else-if="state === 'done'"
|
||||
i-solar:check-circle-bold-duotone class="mr-1 inline-block text-emerald-500"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
|
||||
/>
|
||||
<code class="truncate text-xs">{{ toolName }}</code>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Re-run tool call"
|
||||
:class="[
|
||||
'h-6 w-6 shrink-0 rounded-md',
|
||||
'inline-flex items-center justify-center',
|
||||
'text-primary-700/70 hover:bg-primary-200/70 hover:text-primary-800',
|
||||
'dark:text-primary-100/70 dark:hover:bg-primary-800/70 dark:hover:text-primary-50',
|
||||
]"
|
||||
@click.stop="emitToolCallRerun"
|
||||
>
|
||||
<div class="i-solar:refresh-bold text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
:class="[
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { resolveLlmTools, toolNameFrom } from './llm-tool-resolver'
|
||||
|
||||
function createTool(name: string, description = `${name} description`): Tool {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
execute: vi.fn(),
|
||||
} as Tool
|
||||
}
|
||||
|
||||
describe('toolNameFrom', () => {
|
||||
it('reads function.name', () => {
|
||||
expect(toolNameFrom(createTool('runtime_read_context'))).toBe('runtime_read_context')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLlmTools', () => {
|
||||
it('prefers a later runtime tool with the same name over an earlier built-in tool', async () => {
|
||||
const builtInTool = createTool('duplicate_tool', 'Built-in version.')
|
||||
const runtimeTool = createTool('duplicate_tool', 'Runtime version.')
|
||||
|
||||
const tools = await resolveLlmTools({
|
||||
builtInTools: [builtInTool],
|
||||
debugTools: [],
|
||||
sparkCommandTools: [],
|
||||
activeTools: [runtimeTool],
|
||||
})
|
||||
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(tools[0]).toBe(runtimeTool)
|
||||
})
|
||||
|
||||
it('places custom tools before active runtime tools so runtime tools can win by name', async () => {
|
||||
const builtInTool = createTool('built_in_tool')
|
||||
const customTool = createTool('duplicate_tool', 'Custom version.')
|
||||
const runtimeTool = createTool('duplicate_tool', 'Runtime version.')
|
||||
|
||||
const tools = await resolveLlmTools({
|
||||
builtInTools: [builtInTool],
|
||||
debugTools: [],
|
||||
sparkCommandTools: [],
|
||||
customTools: [customTool],
|
||||
activeTools: [runtimeTool],
|
||||
})
|
||||
|
||||
expect(tools).toEqual([builtInTool, runtimeTool])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { StreamOptions } from '@proj-airi/core-agent'
|
||||
import type { WebSocketEvents } from '@proj-airi/server-sdk'
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { uniqBy } from 'es-toolkit'
|
||||
|
||||
import { createSparkCommandTool, debug, mcp } from '../tools'
|
||||
import { useLlmToolsStore } from './llm-tools'
|
||||
import { useModsServerChannelStore } from './mods/api/channel-server'
|
||||
|
||||
type ToolSource = Tool[] | (() => Promise<Tool[]>)
|
||||
|
||||
/**
|
||||
* Overrides for resolving the complete LLM-visible tool list.
|
||||
*
|
||||
* Production callers normally pass only {@link customTools}; tests can inject
|
||||
* every source to exercise merge and precedence policy without real stores.
|
||||
*/
|
||||
export interface ResolveLlmToolsOptions {
|
||||
/**
|
||||
* MCP-backed built-in tools.
|
||||
*
|
||||
* @default mcp()
|
||||
*/
|
||||
builtInTools?: ToolSource
|
||||
/**
|
||||
* Debug tools exposed to the LLM.
|
||||
*
|
||||
* @default debug()
|
||||
*/
|
||||
debugTools?: ToolSource
|
||||
/**
|
||||
* Spark command tools. Supplying this also avoids creating the mods server
|
||||
* channel store.
|
||||
*
|
||||
* @default createSparkCommandTool(...)
|
||||
*/
|
||||
sparkCommandTools?: ToolSource
|
||||
/**
|
||||
* Request-scoped tools from {@link StreamOptions.tools}. These are ordered
|
||||
* before active runtime tools so runtime registrations can intentionally
|
||||
* override a request tool with the same name.
|
||||
*/
|
||||
customTools?: StreamOptions['tools']
|
||||
/**
|
||||
* Runtime-registered tools currently active in the LLM tool store. Supplying
|
||||
* this also avoids creating the LLM tool store.
|
||||
*
|
||||
* @default useLlmToolsStore().activeTools
|
||||
*/
|
||||
activeTools?: Tool[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the provider-visible name from an xsai tool.
|
||||
*/
|
||||
export function toolNameFrom(tool: Tool): string | undefined {
|
||||
const candidate = tool as Tool & {
|
||||
name?: string
|
||||
function?: {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
return candidate.function?.name ?? candidate.name
|
||||
}
|
||||
|
||||
async function resolveToolSource(source: ToolSource): Promise<Tool[]> {
|
||||
return typeof source === 'function' ? await source() : source
|
||||
}
|
||||
|
||||
async function resolveCustomTools(customTools: StreamOptions['tools']): Promise<Tool[]> {
|
||||
if (typeof customTools === 'function')
|
||||
return await customTools() ?? []
|
||||
|
||||
return customTools ?? []
|
||||
}
|
||||
|
||||
async function resolveActiveTools(activeTools?: Tool[]): Promise<Tool[]> {
|
||||
if (activeTools != null)
|
||||
return activeTools
|
||||
|
||||
const llmToolsStore = useLlmToolsStore()
|
||||
await llmToolsStore.awaitPendingRegistrations()
|
||||
return llmToolsStore.activeTools
|
||||
}
|
||||
|
||||
async function resolveSparkCommandTools(sparkCommandTools?: ToolSource): Promise<Tool[]> {
|
||||
if (sparkCommandTools != null)
|
||||
return resolveToolSource(sparkCommandTools)
|
||||
|
||||
const modsServerChannelStore = useModsServerChannelStore()
|
||||
const sendSparkCommand = (command: WebSocketEvents['spark:command']) => {
|
||||
// TODO(@nekomeowww): instruct the LLM to understand what destination is.
|
||||
// Currently without skill like prompt injection, many issues occur.
|
||||
// destination mostly are wrong or hallucinated, we need to find a way to make it more reliable.
|
||||
//
|
||||
// For now, since destinations as array will always broadcast to all connected modules/agents, we can set it to
|
||||
// empty array to avoid wrong routing.
|
||||
command.destinations = []
|
||||
|
||||
modsServerChannelStore.send({
|
||||
type: 'spark:command',
|
||||
data: command,
|
||||
})
|
||||
}
|
||||
|
||||
return createSparkCommandTool({ sendSparkCommand })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every tool visible to an LLM request.
|
||||
*
|
||||
* Runtime tools are placed last before de-duplication. The reverse/uniq/reverse
|
||||
* pass preserves the existing stable order while letting later runtime
|
||||
* registrations win when names collide with built-in or custom tools.
|
||||
*/
|
||||
export async function resolveLlmTools(options: ResolveLlmToolsOptions = {}): Promise<Tool[]> {
|
||||
const activeTools = await resolveActiveTools(options.activeTools)
|
||||
const [
|
||||
builtInTools,
|
||||
debugTools,
|
||||
sparkCommandTools,
|
||||
customTools,
|
||||
] = await Promise.all([
|
||||
resolveToolSource(options.builtInTools ?? mcp),
|
||||
resolveToolSource(options.debugTools ?? debug),
|
||||
resolveSparkCommandTools(options.sparkCommandTools),
|
||||
resolveCustomTools(options.customTools),
|
||||
])
|
||||
|
||||
return uniqBy(
|
||||
[
|
||||
...builtInTools,
|
||||
...debugTools,
|
||||
...sparkCommandTools,
|
||||
...customTools,
|
||||
...activeTools,
|
||||
].toReversed(),
|
||||
tool => toolNameFrom(tool) ?? tool,
|
||||
).toReversed()
|
||||
}
|
||||
@@ -1,77 +1,32 @@
|
||||
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, Tool } from '@xsai/shared-chat'
|
||||
import type { Message } from '@xsai/shared-chat'
|
||||
|
||||
import { streamFrom as coreStreamFrom, isContentArrayRelatedError, 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'
|
||||
import { resolveLlmTools } from './llm-tool-resolver'
|
||||
|
||||
export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent'
|
||||
export { isContentArrayRelatedError, 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<Map<string, boolean>>(new Map())
|
||||
const contentArrayCompatibility = ref<Map<string, boolean>>(new Map())
|
||||
const modsServerChannelStore = useModsServerChannelStore()
|
||||
const llmToolsStore = useLlmToolsStore()
|
||||
|
||||
async function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
|
||||
const key = modelKey(model, chatProvider)
|
||||
// TODO(@nekomeowww,@shinohara-rin): we should not register the command callback on every stream anyway...
|
||||
const sendSparkCommand = (command: WebSocketEvents['spark:command']) => {
|
||||
// TODO(@nekomeowww): instruct the LLM to understand what destination is.
|
||||
// Currently without skill like prompt injection, many issues occur.
|
||||
// destination mostly are wrong or hallucinated, we need to find a way to make it more reliable.
|
||||
//
|
||||
// For now, since destinations as array will always broadcast to all connected modules/agents, we can set it to
|
||||
// empty array to avoid wrong routing.
|
||||
command.destinations = []
|
||||
|
||||
modsServerChannelStore.send({
|
||||
type: 'spark:command',
|
||||
data: command,
|
||||
})
|
||||
}
|
||||
|
||||
const 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 }),
|
||||
...await llmToolsStore.activeTools,
|
||||
].toReversed(),
|
||||
tool => toolNameFrom(tool) ?? tool,
|
||||
).toReversed()
|
||||
}
|
||||
const { tools: customTools, ...streamOptions } = options ?? {}
|
||||
const builtinToolsResolver = () => resolveLlmTools({ customTools })
|
||||
|
||||
const runStream = () => coreStreamFrom({
|
||||
model,
|
||||
chatProvider,
|
||||
messages,
|
||||
options: {
|
||||
...options,
|
||||
...streamOptions,
|
||||
toolsCompatibility: toolsCompatibility.value,
|
||||
contentArrayCompatibility: contentArrayCompatibility.value,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type { ChatAssistantMessage, ChatHistoryItem } from '../types/chat'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { executeToolCallRerun, replaceToolCallResult } from './tool-call-rerun'
|
||||
|
||||
function assistantMessage(overrides: Partial<ChatAssistantMessage> = {}): ChatAssistantMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
slices: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCall: {
|
||||
toolCallId: 'call-weather',
|
||||
toolCallType: 'function',
|
||||
toolName: 'weather',
|
||||
args: JSON.stringify({ location: 'Tokyo' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_results: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function tool(name: string, execute: Tool['execute']): Tool {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
description: `${name} description`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
execute,
|
||||
}
|
||||
}
|
||||
|
||||
describe('replaceToolCallResult', () => {
|
||||
it('replaces stored tool_results by id', () => {
|
||||
const message = assistantMessage({
|
||||
content: 'assistant content',
|
||||
tool_results: [
|
||||
{ id: 'call-weather', result: 'old weather' },
|
||||
{ id: 'call-news', result: 'news' },
|
||||
],
|
||||
})
|
||||
|
||||
const next = replaceToolCallResult(message, {
|
||||
id: 'call-weather',
|
||||
result: 'new weather',
|
||||
})
|
||||
|
||||
expect(next).not.toBe(message)
|
||||
expect(next.content).toBe('assistant content')
|
||||
expect(next.tool_results).toEqual([
|
||||
{ id: 'call-news', result: 'news' },
|
||||
{ id: 'call-weather', result: 'new weather' },
|
||||
])
|
||||
})
|
||||
|
||||
it('replaces matching inline tool-call-result slice', () => {
|
||||
const message = assistantMessage({
|
||||
slices: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCall: {
|
||||
toolCallId: 'call-weather',
|
||||
toolCallType: 'function',
|
||||
toolName: 'weather',
|
||||
args: JSON.stringify({ location: 'Tokyo' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool-call-result',
|
||||
id: 'call-weather',
|
||||
result: 'old weather',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const next = replaceToolCallResult(message, {
|
||||
id: 'call-weather',
|
||||
isError: true,
|
||||
result: 'new error',
|
||||
})
|
||||
|
||||
expect(next.slices).toEqual([
|
||||
message.slices[0],
|
||||
{
|
||||
type: 'tool-call-result',
|
||||
id: 'call-weather',
|
||||
isError: true,
|
||||
result: 'new error',
|
||||
},
|
||||
])
|
||||
expect(next.tool_results).toEqual([
|
||||
{
|
||||
id: 'call-weather',
|
||||
isError: true,
|
||||
result: 'new error',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeToolCallRerun', () => {
|
||||
it('executes the matching tool and writes the result', async () => {
|
||||
const execute = vi.fn<Tool['execute']>(async () => 'clear skies')
|
||||
const targetMessage: ChatHistoryItem = {
|
||||
...assistantMessage(),
|
||||
id: 'assistant-1',
|
||||
}
|
||||
const messages: ChatHistoryItem[] = [
|
||||
{ role: 'user', content: 'weather?', id: 'user-1' },
|
||||
{ role: 'error', content: 'previous runtime error', id: 'error-1' },
|
||||
targetMessage,
|
||||
]
|
||||
|
||||
const next = await executeToolCallRerun({
|
||||
messages,
|
||||
payload: {
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'call-weather',
|
||||
toolName: 'weather',
|
||||
args: '{ "location": "Tokyo" }',
|
||||
},
|
||||
resolveTools: async () => [tool('weather', execute)],
|
||||
})
|
||||
|
||||
expect(execute).toHaveBeenCalledWith({ location: 'Tokyo' }, {
|
||||
toolCallId: 'call-weather',
|
||||
messages,
|
||||
})
|
||||
expect(next).not.toBe(messages)
|
||||
expect(next[2]).toMatchObject({
|
||||
tool_results: [
|
||||
{
|
||||
id: 'call-weather',
|
||||
result: 'clear skies',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('writes an error result when the tool is unavailable', async () => {
|
||||
const messages: ChatHistoryItem[] = [
|
||||
{
|
||||
...assistantMessage(),
|
||||
id: 'assistant-1',
|
||||
},
|
||||
]
|
||||
|
||||
const next = await executeToolCallRerun({
|
||||
messages,
|
||||
payload: {
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'call-weather',
|
||||
toolName: 'weather',
|
||||
args: '{}',
|
||||
},
|
||||
resolveTools: async () => [],
|
||||
})
|
||||
|
||||
expect(next[0]).toMatchObject({
|
||||
tool_results: [
|
||||
{
|
||||
id: 'call-weather',
|
||||
isError: true,
|
||||
result: 'Tool "weather" is not available for rerun in this runtime.',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('writes an error result for invalid JSON args', async () => {
|
||||
const execute = vi.fn<Tool['execute']>(async () => 'unused')
|
||||
const resolveTools = vi.fn<() => Promise<Tool[]>>(async () => [tool('weather', execute)])
|
||||
const messages: ChatHistoryItem[] = [
|
||||
{
|
||||
...assistantMessage(),
|
||||
id: 'assistant-1',
|
||||
},
|
||||
]
|
||||
|
||||
const next = await executeToolCallRerun({
|
||||
messages,
|
||||
payload: {
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'call-weather',
|
||||
toolName: 'weather',
|
||||
args: '{ invalid',
|
||||
},
|
||||
resolveTools,
|
||||
})
|
||||
|
||||
expect(resolveTools).toHaveBeenCalledTimes(1)
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
expect(next[0]).toMatchObject({
|
||||
tool_results: [
|
||||
{
|
||||
id: 'call-weather',
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect((next[0] as ChatAssistantMessage).tool_results[0]?.result).toContain('Invalid tool call arguments JSON:')
|
||||
})
|
||||
|
||||
it('writes an error result when the tool throws', async () => {
|
||||
const messages: ChatHistoryItem[] = [
|
||||
{
|
||||
...assistantMessage(),
|
||||
id: 'assistant-1',
|
||||
},
|
||||
]
|
||||
|
||||
const next = await executeToolCallRerun({
|
||||
messages,
|
||||
payload: {
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'call-weather',
|
||||
toolName: 'weather',
|
||||
args: '',
|
||||
},
|
||||
resolveTools: async () => [tool('weather', async () => {
|
||||
throw new Error('network unavailable')
|
||||
})],
|
||||
})
|
||||
|
||||
expect(next[0]).toMatchObject({
|
||||
tool_results: [
|
||||
{
|
||||
id: 'call-weather',
|
||||
isError: true,
|
||||
result: 'Tool call error for "weather": network unavailable',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlicesToolCallResult } from '../types/chat'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { toolNameFrom } from './llm-tool-resolver'
|
||||
|
||||
export interface ToolCallRerunPayload<TToolset extends string = string> {
|
||||
sessionId?: string
|
||||
messageId?: string
|
||||
index?: number
|
||||
toolset?: TToolset
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
args: string
|
||||
}
|
||||
|
||||
interface ExecuteToolCallRerunOptions<TToolset extends string = string> {
|
||||
messages: ChatHistoryItem[]
|
||||
payload: ToolCallRerunPayload<TToolset>
|
||||
resolveTools: () => Promise<Tool[]>
|
||||
}
|
||||
|
||||
type ToolCallResultInput = Omit<ChatSlicesToolCallResult, 'type'>
|
||||
type ToolExecuteOptions = NonNullable<Parameters<Tool['execute']>[1]>
|
||||
|
||||
/**
|
||||
* Returns a copy of an assistant message with the result for one tool call replaced.
|
||||
*
|
||||
* The chat UI can read results from `tool_results` or inline `tool-call-result`
|
||||
* slices. Reruns update both representations for the same id so stored and
|
||||
* inline messages stay consistent.
|
||||
*/
|
||||
export function replaceToolCallResult(message: ChatAssistantMessage, result: ToolCallResultInput): ChatAssistantMessage {
|
||||
const toolResult = {
|
||||
id: result.id,
|
||||
isError: result.isError,
|
||||
result: result.result,
|
||||
}
|
||||
const resultSlice: ChatSlicesToolCallResult = {
|
||||
type: 'tool-call-result',
|
||||
...toolResult,
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
slices: message.slices.map((slice) => {
|
||||
if (slice.type === 'tool-call-result' && slice.id === result.id)
|
||||
return resultSlice
|
||||
|
||||
return slice
|
||||
}),
|
||||
tool_results: [
|
||||
...message.tool_results.filter(item => item.id !== result.id),
|
||||
toolResult,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-executes a stored tool call with supplied arguments and returns updated chat history.
|
||||
*
|
||||
* The resolver is injected so callers can choose the runtime-specific tool list
|
||||
* without coupling this helper to app-local stores, Electron IPC, or browser state.
|
||||
*/
|
||||
export async function executeToolCallRerun<TToolset extends string = string>(
|
||||
options: ExecuteToolCallRerunOptions<TToolset>,
|
||||
): Promise<ChatHistoryItem[]> {
|
||||
const { messages, payload } = options
|
||||
const targetIndex = findTargetMessageIndex(messages, payload)
|
||||
const targetMessage = messages[targetIndex]
|
||||
|
||||
if (targetMessage?.role !== 'assistant')
|
||||
throw new Error('Tool call rerun target must be an assistant message.')
|
||||
|
||||
if (!hasMatchingToolCall(targetMessage, payload))
|
||||
throw new Error(`Assistant message does not contain tool call "${payload.toolCallId}" for "${payload.toolName}".`)
|
||||
|
||||
const replaceTargetMessage = (result: ToolCallResultInput) => messages.map((item, itemIndex) => {
|
||||
if (itemIndex !== targetIndex)
|
||||
return item
|
||||
|
||||
return replaceToolCallResult(targetMessage, result)
|
||||
})
|
||||
|
||||
const tools = await options.resolveTools()
|
||||
const tool = tools.find(candidate => toolNameFrom(candidate) === payload.toolName)
|
||||
if (tool == null) {
|
||||
return replaceTargetMessage({
|
||||
id: payload.toolCallId,
|
||||
isError: true,
|
||||
result: `Tool "${payload.toolName}" is not available for rerun in this runtime.`,
|
||||
})
|
||||
}
|
||||
|
||||
const parsedArgs = parseToolCallArgs(payload.args)
|
||||
if (!parsedArgs.ok) {
|
||||
return replaceTargetMessage({
|
||||
id: payload.toolCallId,
|
||||
isError: true,
|
||||
result: `Invalid tool call arguments JSON: ${parsedArgs.message}`,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// NOTICE:
|
||||
// Re-run tools receive AIRI's original chat history so runtime tools can
|
||||
// inspect the same context the UI is updating. xsai types narrow
|
||||
// `messages` to provider `Message[]`, while AIRI history can also contain
|
||||
// local-only `error` entries. Keep the cast at this boundary instead of
|
||||
// filtering messages and silently changing the tool's context.
|
||||
// Removal condition: xsai exposes a tool execution context type that can
|
||||
// accept runtime-owned message envelopes.
|
||||
const executeOptions: ToolExecuteOptions = {
|
||||
toolCallId: payload.toolCallId,
|
||||
messages,
|
||||
} as ToolExecuteOptions
|
||||
const result = await tool.execute(parsedArgs.value, executeOptions)
|
||||
const normalizedResult = typeof result === 'string' || Array.isArray(result)
|
||||
? result
|
||||
: JSON.stringify(result)
|
||||
|
||||
return replaceTargetMessage({
|
||||
id: payload.toolCallId,
|
||||
result: normalizedResult,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
return replaceTargetMessage({
|
||||
id: payload.toolCallId,
|
||||
isError: true,
|
||||
result: `Tool call error for "${payload.toolName}": ${errorMessageFrom(error) ?? String(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function findTargetMessageIndex(messages: ChatHistoryItem[], payload: ToolCallRerunPayload): number {
|
||||
if (payload.messageId != null) {
|
||||
const index = messages.findIndex(message => message.id === payload.messageId)
|
||||
if (index !== -1)
|
||||
return index
|
||||
}
|
||||
|
||||
if (payload.index != null)
|
||||
return payload.index
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function hasMatchingToolCall(message: ChatAssistantMessage, payload: ToolCallRerunPayload): boolean {
|
||||
return message.slices.some(slice =>
|
||||
slice.type === 'tool-call'
|
||||
&& slice.toolCall.toolCallId === payload.toolCallId
|
||||
&& slice.toolCall.toolName === payload.toolName,
|
||||
)
|
||||
}
|
||||
|
||||
function parseToolCallArgs(args: string): { ok: true, value: unknown } | { ok: false, message: string } {
|
||||
const trimmedArgs = args.trim()
|
||||
if (trimmedArgs === '')
|
||||
return { ok: true, value: {} }
|
||||
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(trimmedArgs) as unknown }
|
||||
}
|
||||
catch (error) {
|
||||
return { ok: false, message: errorMessageFrom(error) ?? String(error) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user