git-subtree-dir: aiui git-subtree-mainline:0c4826f8ccgit-subtree-split:e30ac1d106
267 lines
7.7 KiB
TypeScript
267 lines
7.7 KiB
TypeScript
/**
|
|
* Archy Bridge — postMessage client for AIUI ↔ Archipelago communication.
|
|
*
|
|
* This is the ONLY way AIUI should communicate with the host Archy app.
|
|
* Never make direct HTTP requests to the host machine.
|
|
*/
|
|
|
|
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin'
|
|
|
|
interface ContextResponse {
|
|
data: unknown
|
|
permitted: boolean
|
|
}
|
|
|
|
export interface ActionResponse {
|
|
success: boolean
|
|
error?: string
|
|
}
|
|
|
|
interface ThemeInfo {
|
|
accent: string
|
|
mode: 'dark'
|
|
}
|
|
|
|
type PermissionsCallback = (categories: AIContextCategory[]) => void
|
|
type ThemeCallback = (theme: ThemeInfo) => void
|
|
|
|
let requestId = 0
|
|
const pendingRequests = new Map<string, {
|
|
resolve: (value: unknown) => void
|
|
reject: (reason: unknown) => void
|
|
}>()
|
|
|
|
const permissionsCallbacks: PermissionsCallback[] = []
|
|
const themeCallbacks: ThemeCallback[] = []
|
|
let currentPermissions: AIContextCategory[] = []
|
|
let currentTheme: ThemeInfo | null = null
|
|
let initialized = false
|
|
let allowedOrigin: string | null = null
|
|
|
|
function generateId(): string {
|
|
return `aiui-${++requestId}-${Date.now()}`
|
|
}
|
|
|
|
function postToParent(msg: unknown) {
|
|
if (window.parent === window) return // Not in iframe
|
|
if (!allowedOrigin) return // Origin not configured
|
|
window.parent.postMessage(msg, allowedOrigin)
|
|
}
|
|
|
|
function handleMessage(event: MessageEvent) {
|
|
// Always validate origin — reject if not configured or mismatched
|
|
if (!allowedOrigin || event.origin !== allowedOrigin) return
|
|
|
|
const msg = event.data
|
|
if (!msg || typeof msg.type !== 'string') return
|
|
|
|
switch (msg.type) {
|
|
case 'context:response': {
|
|
const pending = pendingRequests.get(msg.id)
|
|
if (pending) {
|
|
pendingRequests.delete(msg.id)
|
|
pending.resolve({ data: msg.data, permitted: msg.permitted })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'action:response': {
|
|
const pending = pendingRequests.get(msg.id)
|
|
if (pending) {
|
|
pendingRequests.delete(msg.id)
|
|
pending.resolve({ success: msg.success, error: msg.error })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'chat:response': {
|
|
const pending = pendingRequests.get(msg.id)
|
|
if (pending) {
|
|
pendingRequests.delete(msg.id)
|
|
if (msg.success) {
|
|
pending.resolve({ text: msg.text ?? '' })
|
|
} else {
|
|
pending.reject(new Error(msg.error || 'Chat request failed'))
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'permissions:update': {
|
|
currentPermissions = msg.categories || []
|
|
for (const cb of permissionsCallbacks) cb(currentPermissions)
|
|
break
|
|
}
|
|
|
|
case 'theme:response': {
|
|
const theme: ThemeInfo | undefined = msg.theme
|
|
if (theme) {
|
|
currentTheme = theme
|
|
for (const cb of themeCallbacks) cb(theme)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The parent (Archy) page's origin — NOT this iframe's own origin. This is
|
|
* what `postToParent` must pass as postMessage's target origin, and what
|
|
* `handleMessage` must validate incoming messages against. Getting this
|
|
* backwards (using `window.location.origin`, this iframe's own origin) works
|
|
* by coincidence only when Archy serves AIUI same-origin (production's
|
|
* `/aiui/` proxy), and silently breaks the entire bridge — including the
|
|
* initial 'ready' message — the moment AIUI runs on a different origin than
|
|
* its host page (e.g. a local AIUI dev server embedded via a separate Archy
|
|
* dev server port). `document.referrer` is the standard, cross-origin-safe
|
|
* way an iframed document learns its embedding parent's URL.
|
|
*/
|
|
function deriveParentOrigin(): string {
|
|
if (typeof document !== 'undefined' && document.referrer) {
|
|
try {
|
|
return new URL(document.referrer).origin
|
|
} catch {
|
|
/* fall through to same-origin assumption below */
|
|
}
|
|
}
|
|
return window.location.origin
|
|
}
|
|
|
|
export const archyBridge = {
|
|
/**
|
|
* Initialize the bridge. Call once on app mount.
|
|
* Sends 'ready' to Archy so it knows the iframe is loaded.
|
|
*/
|
|
init(origin?: string) {
|
|
if (initialized) return
|
|
initialized = true
|
|
// Use an explicit origin if the caller has one, otherwise derive the
|
|
// parent's actual origin (not this iframe's own).
|
|
allowedOrigin = origin ?? deriveParentOrigin()
|
|
window.addEventListener('message', handleMessage)
|
|
postToParent({ type: 'ready' })
|
|
},
|
|
|
|
/** Clean up listeners */
|
|
destroy() {
|
|
window.removeEventListener('message', handleMessage)
|
|
pendingRequests.clear()
|
|
initialized = false
|
|
},
|
|
|
|
/** Check if running inside Archy iframe */
|
|
isInArchy(): boolean {
|
|
return window.parent !== window
|
|
},
|
|
|
|
/**
|
|
* Request context data from Archy.
|
|
* Returns { data, permitted }. If permitted is false, the user hasn't enabled this category.
|
|
*/
|
|
requestContext(category: AIContextCategory, query?: string): Promise<ContextResponse> {
|
|
const id = generateId()
|
|
return new Promise((resolve, reject) => {
|
|
pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
postToParent({
|
|
type: 'context:request',
|
|
id,
|
|
category,
|
|
query,
|
|
})
|
|
|
|
// Timeout after 10s
|
|
setTimeout(() => {
|
|
if (pendingRequests.has(id)) {
|
|
pendingRequests.delete(id)
|
|
reject(new Error(`Context request timed out: ${category}`))
|
|
}
|
|
}, 10000)
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Request Archy to perform an action (install app, navigate, etc.)
|
|
*/
|
|
requestAction(action: string, params: Record<string, string> = {}): Promise<ActionResponse> {
|
|
const id = generateId()
|
|
return new Promise((resolve, reject) => {
|
|
pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
postToParent({
|
|
type: 'action:request',
|
|
id,
|
|
action,
|
|
params,
|
|
})
|
|
|
|
setTimeout(() => {
|
|
if (pendingRequests.has(id)) {
|
|
pendingRequests.delete(id)
|
|
reject(new Error(`Action request timed out: ${action}`))
|
|
}
|
|
}, 30000)
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Send a chat turn to Archy's node-side assistant loop (D-01: the model
|
|
* key and the tool-calling loop live on the node, never in this bundle).
|
|
* Returns the assistant's final text answer for this turn.
|
|
*/
|
|
sendChat(text: string): Promise<{ text: string }> {
|
|
const id = generateId()
|
|
return new Promise((resolve, reject) => {
|
|
pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
postToParent({
|
|
type: 'chat:request',
|
|
id,
|
|
text,
|
|
})
|
|
|
|
// Matches the node's ASSISTANT_HTTP_TIMEOUT (180s) — the assistant
|
|
// loop's tool-calling round trip can legitimately take that long.
|
|
setTimeout(() => {
|
|
if (pendingRequests.has(id)) {
|
|
pendingRequests.delete(id)
|
|
reject(new Error('Chat request timed out'))
|
|
}
|
|
}, 180000)
|
|
})
|
|
},
|
|
|
|
/** Request Archy's theme info */
|
|
requestTheme() {
|
|
postToParent({ type: 'theme:request' })
|
|
},
|
|
|
|
/** Register callback for permission updates */
|
|
onPermissionsUpdate(callback: PermissionsCallback): () => void {
|
|
permissionsCallbacks.push(callback)
|
|
// If we already have permissions, fire immediately
|
|
if (currentPermissions.length > 0) callback(currentPermissions)
|
|
return () => {
|
|
const idx = permissionsCallbacks.indexOf(callback)
|
|
if (idx !== -1) permissionsCallbacks.splice(idx, 1)
|
|
}
|
|
},
|
|
|
|
/** Register callback for theme updates */
|
|
onThemeUpdate(callback: ThemeCallback): () => void {
|
|
themeCallbacks.push(callback)
|
|
if (currentTheme) callback(currentTheme)
|
|
return () => {
|
|
const idx = themeCallbacks.indexOf(callback)
|
|
if (idx !== -1) themeCallbacks.splice(idx, 1)
|
|
}
|
|
},
|
|
|
|
/** Get current permitted categories */
|
|
getPermissions(): AIContextCategory[] {
|
|
return [...currentPermissions]
|
|
},
|
|
|
|
/** Get current theme */
|
|
getTheme(): ThemeInfo | null {
|
|
return currentTheme
|
|
},
|
|
}
|