@ateam-ai-mobile/core 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 +389 -0
- package/dist/index.d.ts +389 -0
- package/dist/index.js +1081 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1027 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +27 -0
- package/src/api/client.ts +105 -0
- package/src/api/mcp.ts +104 -0
- package/src/chat/store.ts +938 -0
- package/src/chat/types.ts +76 -0
- package/src/config.ts +118 -0
- package/src/index.ts +72 -0
- package/src/storage/types.ts +29 -0
- package/src/theme/tokens.ts +105 -0
- package/src/utils/emitter.ts +74 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AttachmentPreview } from '../storage/types';
|
|
2
|
+
|
|
3
|
+
export interface ToolCall {
|
|
4
|
+
name: string;
|
|
5
|
+
args: Record<string, unknown>;
|
|
6
|
+
result: unknown;
|
|
7
|
+
skill?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface JobProgress {
|
|
11
|
+
jobId: string;
|
|
12
|
+
/** Actor that owns the running chain — needed when restoring active work. */
|
|
13
|
+
actorId?: string;
|
|
14
|
+
status: 'running' | 'done' | 'error' | 'paused' | 'unknown';
|
|
15
|
+
subStatus?: string;
|
|
16
|
+
phase?: string;
|
|
17
|
+
iteration?: number;
|
|
18
|
+
/** Active skill slug at this point in the chain (e.g. "messaging-agent") */
|
|
19
|
+
skillSlug?: string;
|
|
20
|
+
/** Most recent tool being invoked by the active skill */
|
|
21
|
+
currentTool?: string;
|
|
22
|
+
/** Wall-clock timestamp when the chain started — used for elapsed timer */
|
|
23
|
+
chainStartedAt?: number;
|
|
24
|
+
resultMessage?: string;
|
|
25
|
+
/** UI Action chips to attach to the assistant bubble when this job finishes. */
|
|
26
|
+
resultActions?: UIAction[];
|
|
27
|
+
/** Live progress from adas_emit_progress() — latest event from a running fat tool */
|
|
28
|
+
liveProgress?: { message: string; step?: number; total?: number };
|
|
29
|
+
pendingQuestion?: {
|
|
30
|
+
inputId: string;
|
|
31
|
+
question: string;
|
|
32
|
+
inputType: 'text' | 'choice' | 'confirm';
|
|
33
|
+
options?: string[];
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type MessageAttachment = AttachmentPreview;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* UI Action chip — emitted by the polisher LLM, validated by cp.uiActions_api.
|
|
42
|
+
*/
|
|
43
|
+
export interface UIAction {
|
|
44
|
+
intent: 'view_entity' | 'suggested_action' | 'quick_toggle' | 'cross_plugin';
|
|
45
|
+
label: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
icon?: string;
|
|
48
|
+
/** Short plugin id (e.g. "triggers-panel") — what the LLM picks from the inventory. */
|
|
49
|
+
target: string;
|
|
50
|
+
/** Full plugin id — added by validator, used by the host to navigate. */
|
|
51
|
+
pluginId?: string;
|
|
52
|
+
/** Already-substituted iframe URL — used by web; mobile ignores it. */
|
|
53
|
+
resolvedUrl?: string;
|
|
54
|
+
/** Intent-specific payload. */
|
|
55
|
+
payload: Record<string, any>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Message {
|
|
59
|
+
id: string;
|
|
60
|
+
role: 'user' | 'assistant' | 'system';
|
|
61
|
+
content: string;
|
|
62
|
+
timestamp: Date;
|
|
63
|
+
toolCalls?: ToolCall[];
|
|
64
|
+
jobProgress?: JobProgress;
|
|
65
|
+
/** Skill that handled this turn */
|
|
66
|
+
skillSlug?: string;
|
|
67
|
+
/** Attached images/files */
|
|
68
|
+
attachments?: MessageAttachment[];
|
|
69
|
+
/** UI Action chips beneath assistant bubbles */
|
|
70
|
+
actions?: UIAction[];
|
|
71
|
+
/** Chain id from /api/conversation — used to attach live progress to the
|
|
72
|
+
* correct persisted turn instead of fabricating a duplicate bubble. */
|
|
73
|
+
chainId?: string;
|
|
74
|
+
/** Turn variant. 'notification' = proactive/system-initiated. */
|
|
75
|
+
kind?: 'notification' | string;
|
|
76
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK configuration — the single injection point between the host app and
|
|
3
|
+
* the ADAS core. The host owns authentication, identity, and URL discovery;
|
|
4
|
+
* the SDK reads everything through the config registered via initAdas().
|
|
5
|
+
*
|
|
6
|
+
* There is intentionally ONE active config per JS context (module singleton):
|
|
7
|
+
* the chat store, WebSocket, and API client below it are app-wide singletons
|
|
8
|
+
* too, and a second tenant/backend inside one app is not a supported v1 case.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AttachmentCache } from './storage/types';
|
|
12
|
+
|
|
13
|
+
/** Result of a host-driven token refresh. Mirrors the semantics the API
|
|
14
|
+
* client depends on: only 'auth_failed' logs the user out; 'transient'
|
|
15
|
+
* (network blip, 5xx) keeps the session so the next call can retry. */
|
|
16
|
+
export interface RefreshOutcome {
|
|
17
|
+
ok: 'ok' | 'auth_failed' | 'transient';
|
|
18
|
+
status: number;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Host-implemented auth bridge. The SDK never shows login UI and never
|
|
24
|
+
* stores credentials — the host authenticates its own users and hands the
|
|
25
|
+
* SDK a way to read/refresh the ADAS JWT it minted for them (via the
|
|
26
|
+
* server-to-server token exchange).
|
|
27
|
+
*/
|
|
28
|
+
export interface TokenProvider {
|
|
29
|
+
/** Current ADAS JWT, or null when signed out. Called on every request —
|
|
30
|
+
* keep it synchronous and cheap (read from memory, not disk). */
|
|
31
|
+
getToken(): string | null;
|
|
32
|
+
/** Called on 401. Host should mint/refresh the ADAS token and return the
|
|
33
|
+
* outcome; 'ok' makes the SDK retry the failed request once. */
|
|
34
|
+
refresh(): Promise<RefreshOutcome>;
|
|
35
|
+
/** Auth is genuinely dead (refresh rejected, or retry still 401). Host
|
|
36
|
+
* should treat this as sign-out for the embedded experience. */
|
|
37
|
+
onAuthFailure?: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AdasUser {
|
|
41
|
+
actorId: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
email?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AdasConfig {
|
|
47
|
+
/** Core API base URL, e.g. https://app.ateam-ai.com */
|
|
48
|
+
coreUrl: string;
|
|
49
|
+
/** Voice service base URL (optional — only voice features need it). */
|
|
50
|
+
voiceUrl?: string;
|
|
51
|
+
tokenProvider: TokenProvider;
|
|
52
|
+
/** Tenant slug — informational/telemetry only. The backend derives the
|
|
53
|
+
* tenant from the JWT; the SDK never sends a tenant header. */
|
|
54
|
+
tenant?: string;
|
|
55
|
+
/** Current end user as known to the host. actorId is used as the chat
|
|
56
|
+
* reply context; without it, replies route to 'unknown'. */
|
|
57
|
+
getUser?: () => AdasUser | null;
|
|
58
|
+
/** Optional persistence for outgoing image/file attachments so previews
|
|
59
|
+
* survive app restarts. No-op when omitted (previews live in memory for
|
|
60
|
+
* the session only). @ateam-ai-mobile/ui ships an Expo/RN implementation. */
|
|
61
|
+
attachmentCache?: AttachmentCache;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let _config: AdasConfig | null = null;
|
|
65
|
+
|
|
66
|
+
/** Register the SDK configuration. Call once at app start, before any other
|
|
67
|
+
* SDK API. Calling again replaces the config (e.g. env switch) — combine
|
|
68
|
+
* with chat.clearChat() and closeChatStream() for a clean swap. */
|
|
69
|
+
export function initAdas(config: AdasConfig): void {
|
|
70
|
+
_config = config;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isAdasInitialized(): boolean {
|
|
74
|
+
return _config !== null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getConfig(): AdasConfig {
|
|
78
|
+
if (!_config) {
|
|
79
|
+
throw new Error('[adas] SDK not initialized — call initAdas(config) before using any ADAS API');
|
|
80
|
+
}
|
|
81
|
+
return _config;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getCoreApiUrl(): string {
|
|
85
|
+
return getConfig().coreUrl.replace(/\/$/, '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getCoreWsUrl(): string {
|
|
89
|
+
return getCoreApiUrl().replace(/^http/, 'ws');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getVoiceApiUrl(): string {
|
|
93
|
+
const url = getConfig().voiceUrl;
|
|
94
|
+
if (!url) throw new Error('[adas] voiceUrl not configured');
|
|
95
|
+
return url.replace(/\/$/, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getTenant(): string {
|
|
99
|
+
return getConfig().tenant || '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getUser(): AdasUser | null {
|
|
103
|
+
return getConfig().getUser?.() || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Internal token helpers (thin wrappers over the host's TokenProvider) ──
|
|
107
|
+
|
|
108
|
+
export function getToken(): string | null {
|
|
109
|
+
return getConfig().tokenProvider.getToken();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function refreshToken(): Promise<RefreshOutcome> {
|
|
113
|
+
return getConfig().tokenProvider.refresh();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function notifyAuthFailure(): void {
|
|
117
|
+
getConfig().tokenProvider.onAuthFailure?.();
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ateam-ai-mobile/core — headless ADAS SDK for React Native.
|
|
3
|
+
*
|
|
4
|
+
* The host app owns authentication and navigation; this package provides
|
|
5
|
+
* everything between the ADAS backend and the UI: config/auth bridge, API
|
|
6
|
+
* client, chat store with live job streaming, MCP tool calls, and theme
|
|
7
|
+
* tokens. No Expo dependencies — works in Expo and bare RN apps alike.
|
|
8
|
+
*
|
|
9
|
+
* Quick start:
|
|
10
|
+
* initAdas({
|
|
11
|
+
* coreUrl: 'https://app.ateam-ai.com',
|
|
12
|
+
* tokenProvider: { getToken, refresh, onAuthFailure },
|
|
13
|
+
* getUser: () => ({ actorId: currentUser.id }),
|
|
14
|
+
* });
|
|
15
|
+
* const { messages, sendMessage } = useChatStore();
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Config & auth bridge
|
|
19
|
+
export {
|
|
20
|
+
initAdas,
|
|
21
|
+
isAdasInitialized,
|
|
22
|
+
getConfig,
|
|
23
|
+
getCoreApiUrl,
|
|
24
|
+
getCoreWsUrl,
|
|
25
|
+
getVoiceApiUrl,
|
|
26
|
+
getTenant,
|
|
27
|
+
getUser,
|
|
28
|
+
getToken,
|
|
29
|
+
} from './config';
|
|
30
|
+
export type { AdasConfig, TokenProvider, RefreshOutcome, AdasUser } from './config';
|
|
31
|
+
|
|
32
|
+
// API client
|
|
33
|
+
export { apiFetch, apiFetchRaw } from './api/client';
|
|
34
|
+
|
|
35
|
+
// MCP / connector calls
|
|
36
|
+
export {
|
|
37
|
+
connectorCall,
|
|
38
|
+
mcpFetch,
|
|
39
|
+
mcpToolCall,
|
|
40
|
+
postCommandResult,
|
|
41
|
+
unwrapMcpResult,
|
|
42
|
+
} from './api/mcp';
|
|
43
|
+
|
|
44
|
+
// Chat
|
|
45
|
+
export {
|
|
46
|
+
useChatStore,
|
|
47
|
+
closeChatStream,
|
|
48
|
+
blocksToText,
|
|
49
|
+
TABLE_BLOCK_OPEN,
|
|
50
|
+
TABLE_BLOCK_CLOSE,
|
|
51
|
+
} from './chat/store';
|
|
52
|
+
export type {
|
|
53
|
+
Message,
|
|
54
|
+
MessageAttachment,
|
|
55
|
+
JobProgress,
|
|
56
|
+
UIAction,
|
|
57
|
+
ToolCall,
|
|
58
|
+
} from './chat/types';
|
|
59
|
+
|
|
60
|
+
// Storage adapter contracts
|
|
61
|
+
export type {
|
|
62
|
+
AttachmentCache,
|
|
63
|
+
AttachmentPreview,
|
|
64
|
+
OutgoingAttachment,
|
|
65
|
+
} from './storage/types';
|
|
66
|
+
|
|
67
|
+
// Plugin command bus
|
|
68
|
+
export { pluginCommandEmitter, PluginCommandEmitter } from './utils/emitter';
|
|
69
|
+
export type { BufferedCommand } from './utils/emitter';
|
|
70
|
+
|
|
71
|
+
// Theme tokens
|
|
72
|
+
export { colors, ada, spacing, fontSize, borderRadius } from './theme/tokens';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage adapter contracts. The core is platform-agnostic (no Expo, no
|
|
3
|
+
* AsyncStorage imports) — hosts or @ateam-ai-mobile/ui plug in concrete implementations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Outgoing attachment as sent to /api/chat (base64 payload). */
|
|
7
|
+
export interface OutgoingAttachment {
|
|
8
|
+
data: string;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Attachment as rendered in a message bubble (local or remote preview URI). */
|
|
14
|
+
export interface AttachmentPreview {
|
|
15
|
+
mimeType: string;
|
|
16
|
+
name: string;
|
|
17
|
+
previewUri?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Persists outgoing attachments keyed by jobId so image previews survive
|
|
22
|
+
* app restarts (/api/conversation does not echo user attachments back).
|
|
23
|
+
*/
|
|
24
|
+
export interface AttachmentCache {
|
|
25
|
+
/** Resolve once the index is loaded — get() before this may return undefined. */
|
|
26
|
+
ready(): Promise<void>;
|
|
27
|
+
save(jobId: string, attachments: OutgoingAttachment[]): Promise<void>;
|
|
28
|
+
get(jobId: string): AttachmentPreview[] | undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ADAS design tokens — shared by @ateam-ai-mobile/ui and host apps that want to match.
|
|
2
|
+
|
|
3
|
+
export const colors = {
|
|
4
|
+
dark: {
|
|
5
|
+
bgPrimary: '#0a0e14',
|
|
6
|
+
bgSecondary: '#111820',
|
|
7
|
+
bgTertiary: '#1a2230',
|
|
8
|
+
bgInput: '#0d1219',
|
|
9
|
+
bgHover: '#1e2a3a',
|
|
10
|
+
border: 'rgba(255, 255, 255, 0.08)',
|
|
11
|
+
borderHover: 'rgba(255, 255, 255, 0.15)',
|
|
12
|
+
textPrimary: '#e8ecf1',
|
|
13
|
+
textSecondary: '#8b95a5',
|
|
14
|
+
textMuted: '#5a6577',
|
|
15
|
+
accent: '#2563eb',
|
|
16
|
+
accentHover: '#3b82f6',
|
|
17
|
+
accentSoft: 'rgba(37, 99, 235, 0.15)',
|
|
18
|
+
green: '#16a34a',
|
|
19
|
+
greenSoft: 'rgba(22, 163, 74, 0.15)',
|
|
20
|
+
red: '#dc2626',
|
|
21
|
+
redSoft: 'rgba(220, 38, 38, 0.15)',
|
|
22
|
+
amber: '#f59e0b',
|
|
23
|
+
amberSoft: 'rgba(245, 158, 11, 0.15)',
|
|
24
|
+
purple: '#8b5cf6',
|
|
25
|
+
purpleSoft: 'rgba(139, 92, 246, 0.15)',
|
|
26
|
+
// Message-specific
|
|
27
|
+
userBubble: '#2563eb',
|
|
28
|
+
botBubble: '#1a2230',
|
|
29
|
+
botBubbleBorder: 'rgba(255, 255, 255, 0.08)',
|
|
30
|
+
},
|
|
31
|
+
light: {
|
|
32
|
+
bgPrimary: '#ffffff',
|
|
33
|
+
bgSecondary: '#f8f9fa',
|
|
34
|
+
bgTertiary: '#f1f3f5',
|
|
35
|
+
bgInput: '#ffffff',
|
|
36
|
+
bgHover: '#e9ecef',
|
|
37
|
+
border: 'rgba(0, 0, 0, 0.08)',
|
|
38
|
+
borderHover: 'rgba(0, 0, 0, 0.15)',
|
|
39
|
+
textPrimary: '#1a1a2e',
|
|
40
|
+
textSecondary: '#6b7280',
|
|
41
|
+
textMuted: '#9ca3af',
|
|
42
|
+
accent: '#2563eb',
|
|
43
|
+
accentHover: '#1d4ed8',
|
|
44
|
+
accentSoft: 'rgba(37, 99, 235, 0.1)',
|
|
45
|
+
green: '#16a34a',
|
|
46
|
+
greenSoft: 'rgba(22, 163, 74, 0.1)',
|
|
47
|
+
red: '#dc2626',
|
|
48
|
+
redSoft: 'rgba(220, 38, 38, 0.1)',
|
|
49
|
+
amber: '#f59e0b',
|
|
50
|
+
amberSoft: 'rgba(245, 158, 11, 0.1)',
|
|
51
|
+
purple: '#8b5cf6',
|
|
52
|
+
purpleSoft: 'rgba(139, 92, 246, 0.1)',
|
|
53
|
+
userBubble: '#2563eb',
|
|
54
|
+
botBubble: '#f1f3f5',
|
|
55
|
+
botBubbleBorder: 'rgba(0, 0, 0, 0.08)',
|
|
56
|
+
},
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
// Ada palette — warm, paper, personal. Used by the consumer chat experience.
|
|
60
|
+
export const ada = {
|
|
61
|
+
paper: '#FAF6EE',
|
|
62
|
+
paperSoft: '#F2EADC',
|
|
63
|
+
paperDeep: '#EBE0CC',
|
|
64
|
+
ink: '#1a1410',
|
|
65
|
+
inkSoft: '#5a4d3f',
|
|
66
|
+
inkMuted: '#9a8870',
|
|
67
|
+
border: 'rgba(26, 20, 16, 0.08)',
|
|
68
|
+
borderStrong: 'rgba(26, 20, 16, 0.16)',
|
|
69
|
+
orange: '#FF7A28',
|
|
70
|
+
orangeDeep: '#B8410A',
|
|
71
|
+
orangeSoft: 'rgba(255, 122, 40, 0.14)',
|
|
72
|
+
userBubble: '#1a1410',
|
|
73
|
+
userBubbleText: '#FAF6EE',
|
|
74
|
+
botBubble: '#F2EADC',
|
|
75
|
+
botBubbleBorder: 'rgba(26, 20, 16, 0.06)',
|
|
76
|
+
botBubbleText: '#1a1410',
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
export const spacing = {
|
|
80
|
+
xs: 4,
|
|
81
|
+
sm: 8,
|
|
82
|
+
md: 12,
|
|
83
|
+
lg: 16,
|
|
84
|
+
xl: 20,
|
|
85
|
+
xxl: 24,
|
|
86
|
+
xxxl: 32,
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
export const fontSize = {
|
|
90
|
+
xs: 11,
|
|
91
|
+
sm: 13,
|
|
92
|
+
md: 15,
|
|
93
|
+
lg: 17,
|
|
94
|
+
xl: 20,
|
|
95
|
+
xxl: 24,
|
|
96
|
+
title: 28,
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
export const borderRadius = {
|
|
100
|
+
sm: 8,
|
|
101
|
+
md: 12,
|
|
102
|
+
lg: 16,
|
|
103
|
+
xl: 20,
|
|
104
|
+
full: 9999,
|
|
105
|
+
} as const;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight event emitter bridging the chat job stream → plugin bridges
|
|
3
|
+
* (React Native has no Node 'events' module).
|
|
4
|
+
*
|
|
5
|
+
* Events: 'plugin-command' (raw command), 'focus-plugin' ({ pluginId, props }),
|
|
6
|
+
* 'plugin-close' ({ pluginId }), 'prepare-chat-message' ({ text }).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type Listener = (data: any) => void;
|
|
10
|
+
|
|
11
|
+
export interface BufferedCommand {
|
|
12
|
+
pluginId: string;
|
|
13
|
+
command: string;
|
|
14
|
+
args?: any;
|
|
15
|
+
correlationId?: string;
|
|
16
|
+
_bufferedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RECENT_TTL_MS = 10000;
|
|
20
|
+
const RECENT_MAX = 20;
|
|
21
|
+
|
|
22
|
+
export class PluginCommandEmitter {
|
|
23
|
+
private listeners = new Map<string, Set<Listener>>();
|
|
24
|
+
// Ring buffer of plugin commands seen recently. Lets a plugin that mounts
|
|
25
|
+
// AFTER its first command was emitted (e.g. a surface mounted by the
|
|
26
|
+
// dispatcher in response to focus-plugin) still pick that command up via
|
|
27
|
+
// `drainPluginCommands(pluginId)`. Self-cleans on TTL + size cap.
|
|
28
|
+
private recent: BufferedCommand[] = [];
|
|
29
|
+
|
|
30
|
+
on(event: string, fn: Listener): () => void {
|
|
31
|
+
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
|
32
|
+
this.listeners.get(event)!.add(fn);
|
|
33
|
+
return () => this.off(event, fn);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
off(event: string, fn: Listener): void {
|
|
37
|
+
this.listeners.get(event)?.delete(fn);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
emit(event: string, data: any): void {
|
|
41
|
+
if (event === 'plugin-command' && data && data.pluginId && data.command && data.command !== '__focus') {
|
|
42
|
+
this.recent.push({
|
|
43
|
+
pluginId: data.pluginId,
|
|
44
|
+
command: data.command,
|
|
45
|
+
args: data.args,
|
|
46
|
+
correlationId: data.correlationId,
|
|
47
|
+
_bufferedAt: Date.now(),
|
|
48
|
+
});
|
|
49
|
+
if (this.recent.length > RECENT_MAX) this.recent.shift();
|
|
50
|
+
}
|
|
51
|
+
this.listeners.get(event)?.forEach((fn) => {
|
|
52
|
+
try { fn(data); } catch {}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Pop and return any plugin-commands targeted at `pluginId` that were
|
|
57
|
+
* emitted within the last RECENT_TTL_MS. Used by the native bridge on
|
|
58
|
+
* mount so the surface-dispatcher path doesn't drop the first command. */
|
|
59
|
+
drainPluginCommands(pluginId: string): BufferedCommand[] {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const out: BufferedCommand[] = [];
|
|
62
|
+
const remaining: BufferedCommand[] = [];
|
|
63
|
+
for (const cmd of this.recent) {
|
|
64
|
+
if (now - cmd._bufferedAt > RECENT_TTL_MS) continue; // expired, drop
|
|
65
|
+
if (cmd.pluginId === pluginId) out.push(cmd);
|
|
66
|
+
else remaining.push(cmd);
|
|
67
|
+
}
|
|
68
|
+
this.recent = remaining;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** App-wide bus for plugin commands arriving over the chat stream. */
|
|
74
|
+
export const pluginCommandEmitter = new PluginCommandEmitter();
|