LiveAgentStream.vue
3.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
<template>
<div class="stream">
<div class="bar">
<span class="conn" :class="status">● {{ status }}</span>
<span class="lei" v-if="lastEventId">last-event-id: {{ lastEventId }}</span>
<span class="grow" />
<button class="btn" @click="clear">clear</button>
</div>
<div ref="logEl" class="log">
<div v-for="(e, i) in events" :key="i" class="evt">
<span class="ts">{{ formatTs(e.ts) }}</span>
<span class="kind">[{{ e.kind }}]</span>
<span class="payload">{{ e.payload }}</span>
</div>
<div v-if="!events.length" class="empty">waiting for events…</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
const props = defineProps({ pid: { type: String, required: true } });
const events = ref([]);
const status = ref('connecting');
const lastEventId = ref(null);
const logEl = ref(null);
let es = null;
let reconnectTimer = null;
const MAX_EVENTS = 250;
function connect() {
if (es) try { es.close(); } catch {}
status.value = 'connecting';
const url = `/api/devloop/projects/${props.pid}/stream`;
// EventSource 自带 Last-Event-ID; 用户网络断开自动重连。
// (本应用浏览器会自动从 lastEventId 续传)
try {
es = new EventSource(url);
} catch (e) {
status.value = 'error';
return;
}
es.onopen = () => { status.value = 'connected'; };
es.onerror = () => {
status.value = 'reconnecting';
// EventSource 内置 reconnect, 但浏览器不一定保证 5s 内重连;
// 手动 5s 重试兜底:断后 5s 关掉重新打开,带 Last-Event-ID 由浏览器自动注入。
try { es.close(); } catch {}
es = null;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 5000);
};
// 监听几个核心事件类型(SSE event name)
const listenTo = [
'project.status', 'project.shipped', 'project.run_started',
'l1.wave_start', 'unit.state',
'integration.run_start', 'integration.result',
'triage.result', 'sdd.updated', 'prd.updated',
'human_intervention_needed', 'project.l2_blocked', 'project.l2_exhausted',
'units.reset', 'bug.opened', 'bug.transition',
];
for (const k of listenTo) {
es.addEventListener(k, (ev) => addEvent(k, ev));
}
// generic message handler 兜底
es.onmessage = (ev) => addEvent(ev.type || 'message', ev);
}
function addEvent(kind, ev) {
let payload;
try { payload = JSON.stringify(JSON.parse(ev.data)); } catch { payload = ev.data; }
if (ev.lastEventId) lastEventId.value = ev.lastEventId;
events.value.push({ ts: Date.now(), kind, payload });
if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS);
nextTick(() => {
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
});
}
function formatTs(ts) {
const d = new Date(ts);
return d.toTimeString().slice(0, 8);
}
function clear() { events.value = []; }
onMounted(connect);
onBeforeUnmount(() => {
if (reconnectTimer) clearTimeout(reconnectTimer);
if (es) try { es.close(); } catch {}
});
</script>
<style scoped>
.stream { display:flex; flex-direction:column; height:240px; }
.bar { display:flex; gap:10px; align-items:center; font-size:12px; color:#666; margin-bottom:6px; }
.bar .grow { flex:1; }
.conn { font-weight:600; }
.conn.connecting { color:#888; }
.conn.connected { color:#0a6; }
.conn.reconnecting { color:#c70; }
.conn.error { color:#c00; }
.lei { font-family: ui-monospace, monospace; color:#aaa; }
.log {
flex:1; overflow-y:auto;
background:#0f1115; color:#e6e6e6;
font-family: ui-monospace, monospace; font-size:12px;
padding:10px; border-radius:6px;
}
.evt { display:flex; gap:8px; margin-bottom:4px; }
.ts { color:#7da; flex:0 0 70px; }
.kind { color:#ffce6a; flex:0 0 auto; }
.payload { color:#bbd; overflow-wrap:anywhere; flex:1; }
.empty { color:#666; }
.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:3px 10px; cursor:pointer; font-size:11px; }
</style>