You've already forked agentic-coding-workflow
214 lines
6.3 KiB
TypeScript
214 lines
6.3 KiB
TypeScript
// useAcpStream wraps the ACP WebSocket per spec §10.3:
|
|
// - connect + auto-reconnect once with exponential backoff (1s/3s, then give up)
|
|
// - ?since=lastEventID resumption
|
|
// - send session/prompt / session/cancel (generates client-<uuid> ID)
|
|
// - accumulate events into a ref array
|
|
// - expose sessionStatus + connection-state status
|
|
import { ref, onScopeDispose, type Ref } from 'vue'
|
|
import { openSessionWS, acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error'
|
|
export type SessionStatus = 'starting' | 'running' | 'crashed' | 'exited'
|
|
|
|
export interface UseAcpStreamReturn {
|
|
status: Ref<StreamStatus>
|
|
sessionStatus: Ref<SessionStatus>
|
|
events: Ref<AcpEvent[]>
|
|
pendingPermissions: Ref<PermissionRequest[]>
|
|
prompt: (text: string, agentSessionID: string) => void
|
|
cancel: (agentSessionID: string) => void
|
|
reconnect: () => void
|
|
close: () => void
|
|
}
|
|
|
|
// 把控制帧(ID=0)的 payload 归一为对象,无论 WS 传来字符串还是已解析对象。
|
|
function decodePayload(raw: unknown): Record<string, unknown> {
|
|
if (typeof raw === 'string') {
|
|
try {
|
|
return JSON.parse(raw) as Record<string, unknown>
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
if (raw && typeof raw === 'object') return raw as Record<string, unknown>
|
|
return {}
|
|
}
|
|
|
|
interface ServerStateEvent {
|
|
kind: 'state'
|
|
status: SessionStatus
|
|
}
|
|
|
|
interface SessionTerminatedPayload {
|
|
status?: 'crashed' | 'exited'
|
|
}
|
|
|
|
export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|
const status = ref<StreamStatus>('idle')
|
|
const sessionStatus = ref<SessionStatus>('starting')
|
|
const events = ref<AcpEvent[]>([])
|
|
const pendingPermissions = ref<PermissionRequest[]>([])
|
|
const lastEventID = ref(0)
|
|
|
|
function upsertPermission(p: PermissionRequest) {
|
|
if (!pendingPermissions.value.some((x) => x.id === p.id)) {
|
|
pendingPermissions.value.push(p)
|
|
}
|
|
}
|
|
function removePermission(reqID: string) {
|
|
pendingPermissions.value = pendingPermissions.value.filter((x) => x.id !== reqID)
|
|
}
|
|
|
|
let ws: WebSocket | null = null
|
|
let retried = 0
|
|
let manualClose = false
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const auth = useAuthStore()
|
|
|
|
function connect() {
|
|
if (ws || manualClose) return
|
|
status.value = 'connecting'
|
|
const token = auth.token ?? ''
|
|
const sock = openSessionWS(sessionId, lastEventID.value, token)
|
|
ws = sock
|
|
|
|
sock.onopen = () => {
|
|
status.value = 'streaming'
|
|
retried = 0
|
|
// 控制帧(permission_*)不落 acp_events,重连不补发;连上后主动拉取当前待审。
|
|
acpApi.sessions
|
|
.permissions(sessionId)
|
|
.then((list) => {
|
|
for (const p of list) upsertPermission(p)
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
sock.onmessage = (ev: MessageEvent) => {
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(ev.data as string)
|
|
} catch {
|
|
return
|
|
}
|
|
if (!parsed || typeof parsed !== 'object') return
|
|
const obj = parsed as Record<string, unknown>
|
|
|
|
// 权限请求/决议控制帧(不进 events 流,单独维护 pendingPermissions)。
|
|
if (obj.kind === 'permission_request') {
|
|
const p = decodePayload(obj.payload)
|
|
upsertPermission({
|
|
id: String(p.request_id ?? ''),
|
|
session_id: String(p.session_id ?? ''),
|
|
tool_name: String(p.tool_name ?? ''),
|
|
tool_call: p.tool_call,
|
|
options: Array.isArray(p.options)
|
|
? (p.options as { id: string; name?: string }[])
|
|
: [],
|
|
status: String(p.status ?? 'pending'),
|
|
created_at: String(p.created_at ?? ''),
|
|
})
|
|
return
|
|
}
|
|
if (obj.kind === 'permission_resolved') {
|
|
const p = decodePayload(obj.payload)
|
|
removePermission(String(p.request_id ?? ''))
|
|
return
|
|
}
|
|
|
|
// Initial state event from server
|
|
if (obj.kind === 'state') {
|
|
const s = (parsed as ServerStateEvent).status
|
|
if (s) sessionStatus.value = s
|
|
return
|
|
}
|
|
|
|
// Final session_terminated event
|
|
if (obj.kind === 'session_terminated') {
|
|
let payloadObj: SessionTerminatedPayload = {}
|
|
const rawPayload = obj.payload
|
|
if (typeof rawPayload === 'string') {
|
|
try {
|
|
payloadObj = JSON.parse(rawPayload) as SessionTerminatedPayload
|
|
} catch {
|
|
payloadObj = {}
|
|
}
|
|
} else if (rawPayload && typeof rawPayload === 'object') {
|
|
payloadObj = rawPayload as SessionTerminatedPayload
|
|
}
|
|
sessionStatus.value = payloadObj.status === 'crashed' ? 'crashed' : 'exited'
|
|
events.value.push(parsed as AcpEvent)
|
|
return
|
|
}
|
|
|
|
// Regular event envelope
|
|
const ev2 = parsed as AcpEvent
|
|
if (typeof ev2.id === 'number' && ev2.id > lastEventID.value) {
|
|
lastEventID.value = ev2.id
|
|
}
|
|
events.value.push(ev2)
|
|
}
|
|
|
|
sock.onerror = () => {
|
|
status.value = 'error'
|
|
}
|
|
|
|
sock.onclose = () => {
|
|
ws = null
|
|
if (manualClose) {
|
|
status.value = 'closed'
|
|
return
|
|
}
|
|
if (retried < 2) {
|
|
const delay = retried === 0 ? 1000 : 3000
|
|
retried += 1
|
|
reconnectTimer = setTimeout(connect, delay)
|
|
} else {
|
|
status.value = 'closed'
|
|
}
|
|
}
|
|
}
|
|
|
|
function send(method: string, params: Record<string, unknown>) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
const id = 'client-' + crypto.randomUUID()
|
|
const msg = { jsonrpc: '2.0', id, method, params }
|
|
ws.send(JSON.stringify(msg))
|
|
}
|
|
|
|
function prompt(text: string, agentSessionID: string) {
|
|
send('session/prompt', {
|
|
sessionId: agentSessionID,
|
|
prompt: [{ type: 'text', text }],
|
|
})
|
|
}
|
|
|
|
function cancel(agentSessionID: string) {
|
|
send('session/cancel', { sessionId: agentSessionID })
|
|
}
|
|
|
|
function reconnect() {
|
|
if (ws) ws.close()
|
|
retried = 0
|
|
manualClose = false
|
|
connect()
|
|
}
|
|
|
|
function close() {
|
|
manualClose = true
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer)
|
|
reconnectTimer = null
|
|
}
|
|
if (ws) ws.close()
|
|
status.value = 'closed'
|
|
}
|
|
|
|
connect()
|
|
onScopeDispose(close)
|
|
|
|
return { status, sessionStatus, events, pendingPermissions, prompt, cancel, reconnect, close }
|
|
}
|