Files
zyt/server/app/adminapi/service/AssistantSseProtocol.php
T

65 lines
1.8 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace app\adminapi\service;
/**
* 诊单 AI 助手 SSE 事件状态机:start -> (progress|delta|live_update)* -> done|error。
*/
final class AssistantSseProtocol
{
private const HEARTBEAT_INTERVAL_NS = 10_000_000_000;
private int $seq = 0;
private int $lastOutputAtNs = 0;
private bool $started = false;
private bool $terminal = false;
/** @param array<string,mixed> $payload */
public function encode(string $event, array $payload): ?string
{
if ($this->terminal || !in_array($event, ['start', 'progress', 'delta', 'live_update', 'done', 'error'], true)) {
return null;
}
if ((!$this->started && $event !== 'start') || ($this->started && $event === 'start')) {
return null;
}
$nextSeq = $this->seq + 1;
$encoded = json_encode(
['seq' => $nextSeq] + $payload,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
);
if (!is_string($encoded)) {
return null;
}
$this->seq = $nextSeq;
$this->started = true;
$this->lastOutputAtNs = hrtime(true);
if (in_array($event, ['done', 'error'], true)) {
$this->terminal = true;
}
return 'event: ' . $event . "\n" . 'data: ' . $encoded . "\n\n";
}
/** Keep an idle authenticated stream open without inventing application events. */
public function heartbeat(): ?string
{
if (!$this->started || $this->terminal) return null;
$now = hrtime(true);
if ($now - $this->lastOutputAtNs < self::HEARTBEAT_INTERVAL_NS) return null;
$this->lastOutputAtNs = $now;
return ": keep-alive\n\n";
}
public function isTerminal(): bool
{
return $this->terminal;
}
}