Files
archy/aiui/packages/app/src/services/archyBridge.ts
T
archipelagoandClaude Opus 5 e1624dda08 feat(aiui): receive a Cmd+K handoff and prefill the composer
Completes the 'Talk to AIUI about it' path from the other side. archyBridge
gains a chat:prefill case behind its existing parent-origin validation, and
ChatInput prefills + focuses with the caret at the end.

Prefills rather than auto-sends: the operator sees and can amend the question
before it costs a model call, and a draft they had already started is never
clobbered by a background handoff. Auto-send is the natural seam for the
follow-up that actions things directly.

The bridge buffers a prefill that arrives before the composer mounts (collapsed
chat, mobile content tab) and replays it on registration, so a Cmd+K ask into a
cold frame is not silently dropped. onPrefill returns an unsubscribe so a
remounting composer cannot leak a stale handler.

Verified: vue-tsc clean; AIUI suite 332 passed. The 3 remaining failures
(seed-songs extraction x2, web-search system prompt) are pre-existing — I
confirmed by reverting 13-01's two AIUI files to their parent state and
reproducing the identical 3 failures without any phase-13 change present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:50:23 -04:00

300 lines
8.9 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
}
// Archy's ⌘K search offers "Talk to AIUI about it"; this carries the text
// the operator typed there into our composer.
case 'chat:prefill': {
const text = typeof msg.text === 'string' ? msg.text : ''
if (!text) break
if (prefillCallback) prefillCallback(text)
// Buffer when nothing is listening yet: the composer may not be mounted
// at handshake time (collapsed chat, mobile content tab), and dropping
// the text would silently lose what the operator typed.
else bufferedPrefill = text
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.
*/
let prefillCallback: ((text: string) => void) | null = null
let bufferedPrefill = ''
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' })
},
/**
* Register the composer's prefill handler. Replays a prefill that arrived
* before the composer mounted, so a ⌘K ask is never dropped on a cold frame.
* Returns an unsubscribe so a remounting composer does not leak the old one.
*/
onPrefill(fn: (text: string) => void) {
prefillCallback = fn
if (bufferedPrefill) {
const text = bufferedPrefill
bufferedPrefill = ''
fn(text)
}
return () => {
if (prefillCallback === fn) prefillCallback = null
}
},
/** 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
},
}