df94ed3cd4
- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend - TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos - FlowBoard.vue: Board-first orchestration dashboard panel - live-sync.ts store + live.ts service: SSE-basierte Live-Updates - Nullability-Warnung in HealthController.cs gefixt - nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte - .gitignore: pnpm/corepack local caches ausgeschlossen - docs: architecture-board-first-orchestration.md hinzugefügt - README: Backend Bridge API dokumentiert
95 lines
2.5 KiB
TypeScript
95 lines
2.5 KiB
TypeScript
import { apiFetch } from './api'
|
|
|
|
export type LiveEventName = 'snapshot' | 'update' | 'heartbeat'
|
|
export type LiveMode = 'live' | 'polling'
|
|
|
|
export interface LiveCursorDto {
|
|
sequence: number
|
|
timestamp: string
|
|
mode: LiveMode
|
|
}
|
|
|
|
export interface LiveUpdateEnvelope {
|
|
type: string
|
|
timestamp: string
|
|
payload: unknown
|
|
sequence: number
|
|
channel: string
|
|
}
|
|
|
|
export interface DashboardLiveEventDto {
|
|
envelope: LiveUpdateEnvelope
|
|
cursor: LiveCursorDto
|
|
}
|
|
|
|
export interface OpenDashboardLiveStreamResult {
|
|
closed: Promise<void>
|
|
}
|
|
|
|
export async function openDashboardLiveStream(
|
|
onEvent: (event: LiveEventName, data: unknown) => void,
|
|
options: { forUser?: string; notificationLimit?: number; afterSequence?: number | null; signal?: AbortSignal } = {},
|
|
): Promise<OpenDashboardLiveStreamResult> {
|
|
const params = new URLSearchParams({
|
|
forUser: options.forUser ?? 'bao',
|
|
notificationLimit: String(options.notificationLimit ?? 50),
|
|
})
|
|
if (typeof options.afterSequence === 'number' && Number.isFinite(options.afterSequence) && options.afterSequence > 0) {
|
|
params.set('afterSequence', String(options.afterSequence))
|
|
}
|
|
|
|
const response = await apiFetch(`/api/dashboard/live?${params}`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
|
|
signal: options.signal,
|
|
})
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Live stream unavailable: HTTP ${response.status}`)
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
const flushBlock = (block: string) => {
|
|
const lines = block.split('\n')
|
|
let eventName: LiveEventName = 'update'
|
|
const dataLines: string[] = []
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.trimEnd()
|
|
if (line.startsWith('event:')) eventName = line.slice(6).trim() as LiveEventName
|
|
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
|
}
|
|
|
|
if (!dataLines.length) return
|
|
try {
|
|
onEvent(eventName, JSON.parse(dataLines.join('\n')))
|
|
} catch (error) {
|
|
console.warn('[live] failed to parse event payload', error)
|
|
}
|
|
}
|
|
|
|
const closed = (async () => {
|
|
while (true) {
|
|
const { value, done } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const parts = buffer.split('\n\n')
|
|
buffer = parts.pop() ?? ''
|
|
for (const part of parts) {
|
|
if (part.trim()) flushBlock(part)
|
|
}
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
flushBlock(buffer)
|
|
buffer = ''
|
|
}
|
|
})()
|
|
|
|
return { closed }
|
|
}
|