LiveAgentStream.vue 3.98 KB
<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>