Files
claude-code/packages/remote-control-server/web/src/lib/rcs-transport.ts
2026-05-01 21:39:30 +08:00

364 lines
11 KiB
TypeScript

import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai'
import { getUuid } from '../api/client'
import { generateMessageUuid } from './utils'
import type { SessionEvent, EventPayload } from '../types'
// ============================================================
// SSE Event Bus — shared between SSE listener and transport
// ============================================================
type SSEEventHandler = (event: SessionEvent) => void
class SSEEventBus {
private listeners: Set<SSEEventHandler> = new Set()
private eventSource: EventSource | null = null
private _lastSeqNum = 0
get lastSeqNum() {
return this._lastSeqNum
}
/** Register a listener for SSE events */
onEvent(handler: SSEEventHandler): () => void {
this.listeners.add(handler)
return () => this.listeners.delete(handler)
}
/** Connect to the SSE stream for a session */
connect(sessionId: string): void {
this.disconnect()
const uuid = getUuid()
const url = `/web/sessions/${sessionId}/events?uuid=${encodeURIComponent(uuid)}`
const es = new EventSource(url)
this.eventSource = es
es.addEventListener('message', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as SessionEvent
if (data.seqNum !== undefined && data.seqNum <= this._lastSeqNum) return
if (data.seqNum !== undefined) this._lastSeqNum = data.seqNum
for (const handler of this.listeners) {
handler(data)
}
} catch {
// ignore parse errors
}
})
}
/** Disconnect the SSE stream */
disconnect(): void {
if (this.eventSource) {
this.eventSource.close()
this.eventSource = null
}
this._lastSeqNum = 0
}
}
// Singleton event bus
export const sseBus = new SSEEventBus()
// ============================================================
// RCS ChatTransport — bridges RCS SSE to AI SDK UIMessageChunk
// ============================================================
interface RCSTransportOptions {
sessionId: string
onPermissionRequest?: (event: SessionEvent) => void
onSessionStatus?: (status: string) => void
onError?: (error: string) => void
}
export class RCSTransport implements ChatTransport<UIMessage> {
private sessionId: string
private onPermissionRequest?: (event: SessionEvent) => void
private onSessionStatus?: (status: string) => void
private onError?: (error: string) => void
private unsub: (() => void) | null = null
constructor(options: RCSTransportOptions) {
this.sessionId = options.sessionId
this.onPermissionRequest = options.onPermissionRequest
this.onSessionStatus = options.onSessionStatus
this.onError = options.onError
}
async sendMessages({
messages,
abortSignal,
}: Parameters<ChatTransport<UIMessage>['sendMessages']>[0]): Promise<
ReadableStream<UIMessageChunk>
> {
const lastMessage = messages[messages.length - 1]
if (!lastMessage || lastMessage.role !== 'user') {
// Return empty stream if no user message
return new ReadableStream({ start: c => c.close() })
}
// Extract text from the user message parts
const text = lastMessage.parts
.filter(
(
p: UIMessage['parts'][number],
): p is Extract<typeof p, { type: 'text' }> => p.type === 'text',
)
.map((p: { text: string }) => p.text)
.join('')
if (!text.trim()) {
return new ReadableStream({ start: c => c.close() })
}
// POST user message to the RCS backend
const uuid = getUuid()
const response = await fetch(
`/web/sessions/${this.sessionId}/events?uuid=${encodeURIComponent(uuid)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'user',
uuid: generateMessageUuid(),
content: text,
message: { content: text },
}),
signal: abortSignal,
},
)
if (!response.ok) {
const data = await response
.json()
.catch(() => ({ error: { message: response.statusText } }))
throw new Error(data.error?.message || 'Failed to send message')
}
// Create a ReadableStream from the SSE event bus
// Collects events until the assistant turn is complete
return new ReadableStream<UIMessageChunk>({
start: controller => {
let textId = `text-${Date.now()}`
let started = false
const ensureStarted = () => {
if (!started) {
started = true
controller.enqueue({
type: 'start',
messageId: `msg-${Date.now()}`,
})
}
}
const handler = (event: SessionEvent) => {
const type = event.type
const payload = event.payload || ({} as EventPayload)
// Skip bridge init noise
const serialized = JSON.stringify(event)
if (/Remote Control connecting/i.test(serialized)) return
switch (type) {
// ---- Assistant text ----
case 'assistant': {
const content =
typeof payload.content === 'string' ? payload.content : ''
if (content && content.trim()) {
ensureStarted()
controller.enqueue({ type: 'text-start', id: textId })
controller.enqueue({
type: 'text-delta',
id: textId,
delta: content,
})
controller.enqueue({ type: 'text-end', id: textId })
}
// Check for embedded tool_use blocks
const msg = payload.message as Record<string, unknown> | undefined
if (
msg &&
typeof msg === 'object' &&
Array.isArray(msg.content)
) {
const toolBlocks = (
msg.content as Array<Record<string, unknown>>
).filter(b => b.type === 'tool_use')
for (const block of toolBlocks) {
ensureStarted()
const toolCallId =
(block.id as string) ||
`call-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
controller.enqueue({
type: 'tool-input-available',
toolCallId,
toolName: (block.name as string) || 'tool',
input: block.input || {},
})
}
}
// Finish after assistant message
ensureStarted()
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
cleanup()
break
}
// ---- Tool use events ----
case 'tool_use': {
ensureStarted()
const toolCallId =
((payload as Record<string, unknown>).tool_call_id as string) ||
`call-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
controller.enqueue({
type: 'tool-input-available',
toolCallId,
toolName:
((payload as Record<string, unknown>).tool_name as string) ||
'tool',
input: (payload as Record<string, unknown>).tool_input || {},
})
break
}
// ---- Tool result events ----
case 'tool_result': {
ensureStarted()
const resultCallId =
((payload as Record<string, unknown>).tool_call_id as string) ||
`call-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const output =
typeof (payload as Record<string, unknown>).output === 'string'
? (payload as Record<string, unknown>).output
: (payload as Record<string, unknown>).content || ''
controller.enqueue({
type: 'tool-output-available',
toolCallId: resultCallId,
output: output as string,
})
break
}
// ---- Permission / control requests ----
case 'control_request':
case 'permission_request': {
const req = payload.request as Record<string, unknown> | undefined
if (req && req.subtype === 'can_use_tool') {
// Forward to the UI layer for handling
this.onPermissionRequest?.(event)
}
// Don't close the stream — wait for the response
break
}
// ---- Status events ----
case 'status': {
const msg =
(typeof payload.message === 'string' ? payload.message : '') ||
payload.content ||
''
if (/connecting|waiting|initializing|Remote Control/i.test(msg))
return
break
}
// ---- Session status ----
case 'session_status': {
if (typeof payload.status === 'string') {
this.onSessionStatus?.(payload.status)
if (
payload.status === 'archived' ||
payload.status === 'inactive'
) {
ensureStarted()
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
cleanup()
}
}
break
}
// ---- Errors ----
case 'error': {
ensureStarted()
controller.enqueue({
type: 'error',
errorText: String(
payload.message || payload.content || 'Unknown error',
),
})
controller.enqueue({ type: 'finish', finishReason: 'error' })
controller.close()
cleanup()
break
}
// ---- Interrupt ----
case 'interrupt': {
ensureStarted()
controller.enqueue({
type: 'abort',
reason: 'Session interrupted',
})
controller.close()
cleanup()
break
}
// ---- Skip noise ----
case 'partial_assistant':
case 'result':
case 'result_success':
case 'control_response':
case 'permission_response':
case 'system':
case 'task_state':
case 'automation_state':
return
default:
return
}
}
const cleanup = () => {
if (this.unsub) {
this.unsub()
this.unsub = null
}
}
this.unsub = sseBus.onEvent(handler)
// Handle abort
if (abortSignal) {
const onAbort = () => {
controller.enqueue({ type: 'abort', reason: 'Aborted' })
controller.close()
cleanup()
abortSignal.removeEventListener('abort', onAbort)
}
abortSignal.addEventListener('abort', onAbort)
}
},
})
}
/** Not supported — RCS doesn't have stream resumption */
reconnectToStream(): Promise<ReadableStream<UIMessageChunk> | null> {
return Promise.resolve(null)
}
/** Clean up listeners */
destroy(): void {
if (this.unsub) {
this.unsub()
this.unsub = null
}
}
}