@ateam-ai-mobile/plugin-runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +1129 -0
- package/dist/index.d.ts +1129 -0
- package/dist/index.js +2388 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2369 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/api/plugins.ts +257 -0
- package/src/bridge/native.ts +266 -0
- package/src/bridge/types.ts +268 -0
- package/src/bundles/bundleManager.ts +396 -0
- package/src/bundles/storage.ts +68 -0
- package/src/hooks/usePlugin.ts +185 -0
- package/src/hooks/useRemotePlugins.ts +97 -0
- package/src/host.ts +117 -0
- package/src/index.ts +79 -0
- package/src/registry/SurfaceDispatcher.tsx +66 -0
- package/src/registry/store.ts +131 -0
- package/src/registry/types.ts +60 -0
- package/src/sdk/components.ts +370 -0
- package/src/sdk/index.ts +76 -0
- package/src/sdk/primitives.ts +22 -0
- package/src/sdk/registration.ts +52 -0
- package/src/sdk/types.ts +315 -0
- package/src/sdk/useApi.ts +78 -0
- package/src/sdk/usePluginTheme.ts +86 -0
- package/src/surfaces/AdaOrb.tsx +318 -0
- package/src/surfaces/SurfaceCard.tsx +137 -0
- package/src/surfaces/SurfaceDrawer.tsx +216 -0
- package/src/surfaces/SurfaceFullscreen.tsx +207 -0
- package/src/utils/sha256.ts +85 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { apiFetch } from '@ateam-ai-mobile/core';
|
|
2
|
+
import { getCoreApiUrl } from '@ateam-ai-mobile/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Summary returned by cp.listContextPlugins — no `render` field.
|
|
6
|
+
* Used for the plugin grid (name, icon, description).
|
|
7
|
+
*/
|
|
8
|
+
export interface PluginSurfaceMeta {
|
|
9
|
+
type: 'drawer' | 'card' | 'fullscreen' | 'header' | 'ambient' | 'nudge';
|
|
10
|
+
visibility?: 'always' | 'user' | 'engine';
|
|
11
|
+
trigger?: { from?: string; gesture?: string };
|
|
12
|
+
icon?: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
privacy?: boolean;
|
|
16
|
+
prompt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PluginSummary {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
_source?: string; // "mcp" for connector plugins
|
|
25
|
+
_connectorId?: string;
|
|
26
|
+
/** Declarative surface placement — added by the surface-SDK migration */
|
|
27
|
+
surface?: PluginSurfaceMeta;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Native capability flags (declared in manifest).
|
|
32
|
+
*/
|
|
33
|
+
export interface NativeCapabilities {
|
|
34
|
+
camera?: boolean;
|
|
35
|
+
location?: boolean;
|
|
36
|
+
biometrics?: boolean;
|
|
37
|
+
offline?: boolean;
|
|
38
|
+
backgroundTasks?: boolean;
|
|
39
|
+
fileSystem?: boolean;
|
|
40
|
+
notifications?: boolean;
|
|
41
|
+
bluetooth?: boolean;
|
|
42
|
+
haptics?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lifecycle hooks for service/hybrid plugins.
|
|
47
|
+
*/
|
|
48
|
+
export interface PluginHooks {
|
|
49
|
+
onAppStart?: boolean;
|
|
50
|
+
onPushReceived?: boolean;
|
|
51
|
+
onConnectivity?: boolean;
|
|
52
|
+
onForeground?: boolean;
|
|
53
|
+
onBackground?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render mode — polymorphic.
|
|
58
|
+
* - 'iframe': existing WebView (backward compat)
|
|
59
|
+
* - 'react-native': native RN component only
|
|
60
|
+
* - 'adaptive': both web and native, host picks best
|
|
61
|
+
*/
|
|
62
|
+
export type PluginRenderMode =
|
|
63
|
+
| { mode: 'iframe'; iframeUrl: string; bundleUrl?: string; bundleHash?: string; external?: boolean }
|
|
64
|
+
| { mode: 'react-native'; component: string; bundleUrl?: string; bundleHash?: string; bundleId?: string }
|
|
65
|
+
| {
|
|
66
|
+
mode: 'adaptive';
|
|
67
|
+
iframe: { iframeUrl: string; external?: boolean };
|
|
68
|
+
reactNative: { component: string; bundleUrl?: string; bundleHash?: string; bundleId?: string };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Full manifest returned by cp.getContextPlugin.
|
|
73
|
+
* Extended to support native mobile plugins alongside existing web plugins.
|
|
74
|
+
*/
|
|
75
|
+
export interface PluginManifest extends PluginSummary {
|
|
76
|
+
type?: 'ui' | 'service' | 'hybrid';
|
|
77
|
+
render: PluginRenderMode;
|
|
78
|
+
native?: NativeCapabilities;
|
|
79
|
+
hooks?: PluginHooks;
|
|
80
|
+
channels?: string[] | {
|
|
81
|
+
events: string[];
|
|
82
|
+
actions: string[];
|
|
83
|
+
};
|
|
84
|
+
capabilities?: Record<string, unknown>;
|
|
85
|
+
_mcpEndpoint?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Determine if a manifest supports native rendering.
|
|
90
|
+
*/
|
|
91
|
+
export function supportsNative(manifest: PluginManifest): boolean {
|
|
92
|
+
const mode = manifest.render?.mode;
|
|
93
|
+
return mode === 'react-native' || mode === 'adaptive';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get the native component ID from a manifest (if it has native support).
|
|
98
|
+
*/
|
|
99
|
+
export function getNativeComponentId(manifest: PluginManifest): string | null {
|
|
100
|
+
if (manifest.render?.mode === 'react-native') return manifest.render.component;
|
|
101
|
+
if (manifest.render?.mode === 'adaptive') return manifest.render.reactNative?.component;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get the native bundle URL from a manifest (if it has native support).
|
|
107
|
+
*/
|
|
108
|
+
export function getNativeBundleUrl(manifest: PluginManifest): string | null {
|
|
109
|
+
// Check direct bundleUrl on manifest or within render structure
|
|
110
|
+
const direct = (manifest as any).bundleUrl;
|
|
111
|
+
if (direct) return direct;
|
|
112
|
+
|
|
113
|
+
const render = manifest.render;
|
|
114
|
+
if (render.mode === 'react-native') return render.bundleUrl || null;
|
|
115
|
+
if (render.mode === 'adaptive') return render.reactNative?.bundleUrl || null;
|
|
116
|
+
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the SHA-256 integrity hash for a native bundle, if the server provided one.
|
|
122
|
+
* Used post-download to verify the bundle bytes weren't tampered in transit
|
|
123
|
+
* — a MITM-replaced bundle gets `eval`'d as RN code with full capability access.
|
|
124
|
+
*/
|
|
125
|
+
export function getNativeBundleHash(manifest: PluginManifest): string | null {
|
|
126
|
+
const direct = (manifest as any).bundleHash;
|
|
127
|
+
if (direct) return direct;
|
|
128
|
+
const render = manifest.render;
|
|
129
|
+
if (render.mode === 'react-native') return (render as any).bundleHash || null;
|
|
130
|
+
if (render.mode === 'adaptive') return render.reactNative?.bundleHash || null;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if a manifest can be rendered as a WebView (iframe or adaptive with iframe).
|
|
136
|
+
*/
|
|
137
|
+
export function supportsWebView(manifest: PluginManifest): boolean {
|
|
138
|
+
return manifest.render?.mode === 'iframe' || manifest.render?.mode === 'adaptive';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* List all available UI plugins via MCP JSON-RPC.
|
|
143
|
+
* Returns summary data only (no render field).
|
|
144
|
+
* Protocol: cp.listContextPlugins → core plugins + MCP connector plugins.
|
|
145
|
+
*/
|
|
146
|
+
export async function listPlugins(): Promise<PluginSummary[]> {
|
|
147
|
+
const res = await apiFetch<{
|
|
148
|
+
result: {
|
|
149
|
+
content: Array<{ type: string; text: string }>;
|
|
150
|
+
structuredContent?: { plugins?: PluginSummary[] };
|
|
151
|
+
};
|
|
152
|
+
}>('/mcp', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
jsonrpc: '2.0',
|
|
156
|
+
id: Date.now(),
|
|
157
|
+
method: 'tools/call',
|
|
158
|
+
params: {
|
|
159
|
+
name: 'cp.listContextPlugins',
|
|
160
|
+
arguments: {},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Accept multiple response formats (matches web mcpClient.js fallback chain)
|
|
166
|
+
const structured = res.result?.structuredContent?.plugins;
|
|
167
|
+
if (structured) return structured;
|
|
168
|
+
|
|
169
|
+
const text = res.result?.content?.[0]?.text;
|
|
170
|
+
if (!text) return [];
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(text);
|
|
173
|
+
return parsed.plugins || parsed || [];
|
|
174
|
+
} catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get a specific plugin's FULL manifest (with render.iframeUrl).
|
|
181
|
+
* Protocol: cp.getContextPlugin({ id }) → resolves iframeUrl for core/MCP/external.
|
|
182
|
+
*
|
|
183
|
+
* IMPORTANT: The backend parameter is `id`, not `pluginId`.
|
|
184
|
+
*/
|
|
185
|
+
export async function getPlugin(pluginId: string): Promise<PluginManifest | null> {
|
|
186
|
+
const res = await apiFetch<{
|
|
187
|
+
result: {
|
|
188
|
+
content: Array<{ type: string; text: string }>;
|
|
189
|
+
structuredContent?: PluginManifest;
|
|
190
|
+
};
|
|
191
|
+
}>('/mcp', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
jsonrpc: '2.0',
|
|
195
|
+
id: Date.now(),
|
|
196
|
+
method: 'tools/call',
|
|
197
|
+
params: {
|
|
198
|
+
name: 'cp.getContextPlugin',
|
|
199
|
+
arguments: { id: pluginId }, // Backend expects { id }, NOT { pluginId }
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Accept structured or text response
|
|
205
|
+
if (res.result?.structuredContent) return res.result.structuredContent;
|
|
206
|
+
|
|
207
|
+
const text = res.result?.content?.[0]?.text;
|
|
208
|
+
if (!text) return null;
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(text);
|
|
211
|
+
// Backend may return error object
|
|
212
|
+
if (parsed.error) {
|
|
213
|
+
console.warn('[getPlugin] Backend error:', parsed.error);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
return parsed;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve plugin iframe URL to a full URL for WebView.
|
|
224
|
+
* Matches the web frontend's PluginHost.jsx logic:
|
|
225
|
+
* - External (absolute http): append ?tenant=
|
|
226
|
+
* - MCP plugin (/mcp-ui/...): inject tenant into path → /mcp-ui/{tenant}/{connectorId}/...
|
|
227
|
+
* - Core plugin (/assets/...): prepend getCoreApiUrl()
|
|
228
|
+
*/
|
|
229
|
+
export function resolvePluginUrl(manifest: PluginManifest, tenant?: string): string {
|
|
230
|
+
// Extract iframeUrl from different render modes.
|
|
231
|
+
// Adaptive manifests in the wild use a flat shape — `render.iframeUrl` —
|
|
232
|
+
// not the nested `render.iframe.iframeUrl` from the type. Accept both.
|
|
233
|
+
const render = manifest.render as any;
|
|
234
|
+
let url: string | undefined;
|
|
235
|
+
if (render?.mode === 'iframe') {
|
|
236
|
+
url = render.iframeUrl;
|
|
237
|
+
} else if (render?.mode === 'adaptive') {
|
|
238
|
+
url = render.iframeUrl || render.iframe?.iframeUrl;
|
|
239
|
+
}
|
|
240
|
+
// react-native only → no URL
|
|
241
|
+
if (!url) return '';
|
|
242
|
+
|
|
243
|
+
// Already absolute URL (external plugins like Voice)
|
|
244
|
+
if (url.startsWith('http')) {
|
|
245
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
246
|
+
return tenant ? `${url}${sep}tenant=${encodeURIComponent(tenant)}` : url;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// MCP plugin — inject tenant into path: /mcp-ui/{connectorId}/... → /mcp-ui/{tenant}/{connectorId}/...
|
|
250
|
+
if (tenant && url.startsWith('/mcp-ui/')) {
|
|
251
|
+
const tenantUrl = url.replace('/mcp-ui/', `/mcp-ui/${encodeURIComponent(tenant)}/`);
|
|
252
|
+
return `${getCoreApiUrl()}${tenantUrl}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Core plugin — prepend core API base
|
|
256
|
+
return `${getCoreApiUrl()}${url}`;
|
|
257
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native bridge for React Native plugins.
|
|
3
|
+
* Direct function calls — no postMessage, no serialization.
|
|
4
|
+
*
|
|
5
|
+
* Identity comes from @ateam-ai-mobile/core config (host-owned auth); tool calls go
|
|
6
|
+
* through the same MCP layer as everything else.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useMemo, useRef, useEffect } from 'react';
|
|
9
|
+
import type { MutableRefObject } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
connectorCall,
|
|
12
|
+
mcpFetch as mcpFetchApi,
|
|
13
|
+
unwrapMcpResult,
|
|
14
|
+
postCommandResult,
|
|
15
|
+
getTenant,
|
|
16
|
+
getUser,
|
|
17
|
+
pluginCommandEmitter,
|
|
18
|
+
useChatStore,
|
|
19
|
+
} from '@ateam-ai-mobile/core';
|
|
20
|
+
import type { PluginBridge, PluginContext } from '../sdk/types';
|
|
21
|
+
|
|
22
|
+
type CommandListener = (type: string, payload: any) => void;
|
|
23
|
+
|
|
24
|
+
function buildContext(pluginId: string, connectorId?: string): PluginContext {
|
|
25
|
+
const user = getUser();
|
|
26
|
+
return {
|
|
27
|
+
tenant: getTenant(),
|
|
28
|
+
pluginId,
|
|
29
|
+
connectorId,
|
|
30
|
+
user: user ? { id: user.actorId || '', name: user.name || '', roles: [] } : undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useNativeBridge(pluginId: string, connectorId?: string, commandBufferRef?: MutableRefObject<any[]>): PluginBridge {
|
|
35
|
+
const initCallbackRef = useRef<((ctx: PluginContext) => void) | null>(null);
|
|
36
|
+
const commandListenersRef = useRef<Set<CommandListener>>(new Set());
|
|
37
|
+
const initializedRef = useRef(false);
|
|
38
|
+
|
|
39
|
+
const fireInit = useCallback(() => {
|
|
40
|
+
if (initializedRef.current || !initCallbackRef.current) return;
|
|
41
|
+
initCallbackRef.current(buildContext(pluginId, connectorId));
|
|
42
|
+
initializedRef.current = true;
|
|
43
|
+
}, [pluginId, connectorId]);
|
|
44
|
+
|
|
45
|
+
const onInit = useCallback((cb: (ctx: PluginContext) => void) => {
|
|
46
|
+
initCallbackRef.current = cb;
|
|
47
|
+
setTimeout(fireInit, 0);
|
|
48
|
+
}, [fireInit]);
|
|
49
|
+
|
|
50
|
+
const mcpCall = useCallback(async (toolOrConnector: string, argsOrTool: any, cIdOrArgs?: any): Promise<any> => {
|
|
51
|
+
// Support BOTH argument orders:
|
|
52
|
+
// (tool, args, connectorId?) — bridge standard
|
|
53
|
+
// (connectorId, tool, args?) — plugin convenience (matches useApi pattern)
|
|
54
|
+
// Detect: if 2nd arg is a string, caller used (connector, tool, args) order
|
|
55
|
+
let tool: string;
|
|
56
|
+
let args: any;
|
|
57
|
+
let cId: string | undefined;
|
|
58
|
+
|
|
59
|
+
if (typeof argsOrTool === 'string') {
|
|
60
|
+
// (connectorId, tool, args) order
|
|
61
|
+
cId = toolOrConnector;
|
|
62
|
+
tool = argsOrTool;
|
|
63
|
+
args = cIdOrArgs || {};
|
|
64
|
+
} else {
|
|
65
|
+
// Standard (tool, args, connectorId?) order
|
|
66
|
+
tool = toolOrConnector;
|
|
67
|
+
args = argsOrTool;
|
|
68
|
+
cId = typeof cIdOrArgs === 'string' ? cIdOrArgs : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const targetConnector = cId || connectorId;
|
|
72
|
+
if (!targetConnector) {
|
|
73
|
+
console.warn(`[NativeBridge:${pluginId}] mcpCall failed — no connectorId. tool=${tool}`);
|
|
74
|
+
throw new Error(`[NativeBridge:${pluginId}] mcpCall requires a connectorId`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const data = await connectorCall(targetConnector, tool, args);
|
|
79
|
+
|
|
80
|
+
if (!data.ok && data.error) {
|
|
81
|
+
throw new Error(data.error);
|
|
82
|
+
}
|
|
83
|
+
return unwrapMcpResult(data.result);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.warn(`[NativeBridge:${pluginId}] mcpCall failed → connector=${targetConnector} tool=${tool}:`, (err as any)?.message || err);
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}, [pluginId, connectorId]);
|
|
89
|
+
|
|
90
|
+
const mcpFetch = useCallback(async (body: any): Promise<any> => {
|
|
91
|
+
return mcpFetchApi(body);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const sendCommand = useCallback((type: string, payload: any) => {
|
|
95
|
+
// Handle command results — mirror web PluginHost pattern
|
|
96
|
+
if (type === 'plugin.command.result' && payload?.correlationId) {
|
|
97
|
+
postCommandResult(payload.correlationId, payload.result, payload.error);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}, [pluginId]);
|
|
101
|
+
|
|
102
|
+
const onCommand = useCallback((cb: CommandListener): (() => void) => {
|
|
103
|
+
commandListenersRef.current.add(cb);
|
|
104
|
+
// Flush buffered commands immediately when a listener registers —
|
|
105
|
+
// the buffer may have commands that arrived before the plugin mounted.
|
|
106
|
+
if (commandBufferRef) {
|
|
107
|
+
const buf = commandBufferRef.current;
|
|
108
|
+
const remaining: any[] = [];
|
|
109
|
+
for (const cmd of buf) {
|
|
110
|
+
if (cmd.pluginId === pluginId && cmd.command !== '__focus') {
|
|
111
|
+
try { cb(cmd.command, { ...cmd.args, correlationId: cmd.correlationId }); } catch {}
|
|
112
|
+
} else {
|
|
113
|
+
remaining.push(cmd);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
commandBufferRef.current = remaining;
|
|
117
|
+
}
|
|
118
|
+
// Also drain the emitter's own recent-commands buffer (covers the
|
|
119
|
+
// surface-dispatcher path which has no commandBufferRef).
|
|
120
|
+
for (const cmd of pluginCommandEmitter.drainPluginCommands(pluginId)) {
|
|
121
|
+
try { cb(cmd.command, { ...cmd.args, correlationId: cmd.correlationId }); } catch {}
|
|
122
|
+
}
|
|
123
|
+
return () => {
|
|
124
|
+
commandListenersRef.current.delete(cb);
|
|
125
|
+
};
|
|
126
|
+
}, [pluginId, commandBufferRef]);
|
|
127
|
+
|
|
128
|
+
// Dispatch live plugin commands to registered listeners
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const unsub = pluginCommandEmitter.on('plugin-command', (cmd: any) => {
|
|
131
|
+
if (cmd.pluginId !== pluginId) return;
|
|
132
|
+
if (cmd.command === '__focus') return; // Focus-only, no dispatch
|
|
133
|
+
commandListenersRef.current.forEach(fn => {
|
|
134
|
+
try { fn(cmd.command, { ...cmd.args, correlationId: cmd.correlationId }); } catch {}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
return unsub;
|
|
138
|
+
}, [pluginId]);
|
|
139
|
+
|
|
140
|
+
// Flush buffered commands on mount (commands that arrived before plugin
|
|
141
|
+
// was ready). Two sources, both safe to drain:
|
|
142
|
+
// 1. The parent-passed `commandBufferRef` (legacy overlay path).
|
|
143
|
+
// 2. The emitter's own recent-commands buffer (surface-dispatcher path).
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
const dispatch = (cmd: any) => {
|
|
147
|
+
commandListenersRef.current.forEach(fn => {
|
|
148
|
+
try { fn(cmd.command, { ...cmd.args, correlationId: cmd.correlationId }); } catch {}
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (commandBufferRef) {
|
|
153
|
+
const remaining: any[] = [];
|
|
154
|
+
for (const cmd of commandBufferRef.current) {
|
|
155
|
+
if (cmd.pluginId === pluginId && cmd.command !== '__focus') dispatch(cmd);
|
|
156
|
+
else remaining.push(cmd);
|
|
157
|
+
}
|
|
158
|
+
commandBufferRef.current = remaining;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Always drain the emitter buffer too — the dispatcher path has no
|
|
162
|
+
// parent buffer, and the legacy path may still have lost commands here
|
|
163
|
+
// if they arrived before the parent's listener registered.
|
|
164
|
+
for (const cmd of pluginCommandEmitter.drainPluginCommands(pluginId)) {
|
|
165
|
+
dispatch(cmd);
|
|
166
|
+
}
|
|
167
|
+
}, 500); // Delay to let plugin register its onCommand listener
|
|
168
|
+
return () => clearTimeout(timer);
|
|
169
|
+
}, [pluginId, commandBufferRef]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
return () => {
|
|
173
|
+
commandListenersRef.current.clear();
|
|
174
|
+
initializedRef.current = false;
|
|
175
|
+
};
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const close = useCallback(() => {
|
|
179
|
+
pluginCommandEmitter.emit('plugin-close', { pluginId });
|
|
180
|
+
}, [pluginId]);
|
|
181
|
+
|
|
182
|
+
/** Send a chat message into the user's thread (e.g. confirmed "connect my
|
|
183
|
+
* Gmail"). Pair with bridge.close() to land the user on chat. */
|
|
184
|
+
const sendMessage = useCallback((text: string) => {
|
|
185
|
+
const t = (text || '').trim();
|
|
186
|
+
if (!t) return;
|
|
187
|
+
try {
|
|
188
|
+
useChatStore.getState().sendMessage(t);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.warn('[bridge.sendMessage] failed:', err instanceof Error ? err.message : String(err));
|
|
191
|
+
}
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
/** Prepare (pre-fill) the chat composer without sending. The host chat
|
|
195
|
+
* screen listens for `prepare-chat-message` and stuffs `text` into its
|
|
196
|
+
* input state. Pair with bridge.close(). */
|
|
197
|
+
const prepareMessage = useCallback((text: string) => {
|
|
198
|
+
const t = (text || '').trim();
|
|
199
|
+
if (!t) return;
|
|
200
|
+
pluginCommandEmitter.emit('prepare-chat-message', { text: t });
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
// Memoize the bridge object to prevent child re-renders — a new object
|
|
204
|
+
// every render → useApi recreates → fetch effects refire → flicker loop.
|
|
205
|
+
return useMemo(
|
|
206
|
+
() => ({ onInit, mcpCall, mcpFetch, sendCommand, onCommand, close, sendMessage, prepareMessage }),
|
|
207
|
+
[onInit, mcpCall, mcpFetch, sendCommand, onCommand, close, sendMessage, prepareMessage],
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Non-hook version for service plugins (no React lifecycle).
|
|
213
|
+
*/
|
|
214
|
+
export function createNativeBridge(pluginId: string, connectorId?: string): PluginBridge {
|
|
215
|
+
const commandListeners = new Set<CommandListener>();
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
onInit(cb) {
|
|
219
|
+
setTimeout(() => {
|
|
220
|
+
cb(buildContext(pluginId, connectorId));
|
|
221
|
+
}, 0);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async mcpCall(tool: string, args: any, cId?: string) {
|
|
225
|
+
const targetConnector = cId || connectorId;
|
|
226
|
+
if (!targetConnector) {
|
|
227
|
+
throw new Error(`[NativeBridge:${pluginId}] mcpCall requires a connectorId`);
|
|
228
|
+
}
|
|
229
|
+
const data = await connectorCall(targetConnector, tool, args);
|
|
230
|
+
if (!data.ok && data.error) throw new Error(data.error);
|
|
231
|
+
return unwrapMcpResult(data.result);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async mcpFetch(body: any) {
|
|
235
|
+
return mcpFetchApi(body);
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
sendCommand(_type: string, _payload: any) {
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
onCommand(cb: CommandListener) {
|
|
242
|
+
commandListeners.add(cb);
|
|
243
|
+
return () => { commandListeners.delete(cb); };
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
close() {
|
|
247
|
+
pluginCommandEmitter.emit('plugin-close', { pluginId });
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
sendMessage(text: string) {
|
|
251
|
+
const t = (text || '').trim();
|
|
252
|
+
if (!t) return;
|
|
253
|
+
try {
|
|
254
|
+
useChatStore.getState().sendMessage(t);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.warn('[bridge.sendMessage] failed:', err instanceof Error ? err.message : String(err));
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
prepareMessage(text: string) {
|
|
261
|
+
const t = (text || '').trim();
|
|
262
|
+
if (!t) return;
|
|
263
|
+
pluginCommandEmitter.emit('prepare-chat-message', { text: t });
|
|
264
|
+
},
|
|
265
|
+
} as PluginBridge;
|
|
266
|
+
}
|