This commit is contained in:
gr
2026-10-23 09:27:45 +08:00
34 changed files with 2866 additions and 225 deletions
@@ -23,6 +23,7 @@ use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\service\AssistantSseTiming;
use app\adminapi\service\AssistantRequestControl;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\common\model\Order;
use app\common\model\WechatChatRecord;
@@ -936,135 +937,163 @@ class DiagnosisController extends BaseAdminController
public function aiAssistantStream()
{
$timing = new AssistantSseTiming();
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
$prepared = DiagnosisAiLogic::prepareAssistant(
(int) $params['id'],
(string) $params['task'],
(string) ($params['prompt'] ?? ''),
$this->adminId,
$this->adminInfo,
$timing
);
if ($prepared === null) {
return $this->fail(DiagnosisAiLogic::getError());
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistantStream');
// Old streaming clients may omit request_id; only requests with a known UUID
// can be explicitly cancelled by a second HTTP request.
$requestId = (string) ($params['request_id'] ?? '');
if ($requestId === '') {
$hex = bin2hex(random_bytes(16));
$requestId = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3)
. '-8' . substr($hex, 17, 3) . '-' . substr($hex, 20);
}
$emit = null;
$control = null;
$profile = '';
$sseStartedAt = hrtime(true);
$disconnected = false;
$shouldAbort = static function () use (&$control, &$disconnected): bool {
if (connection_aborted() === 1 && !$disconnected) {
$disconnected = true;
if ($control !== null) $control->signalCancel();
}
return $disconnected || ($control !== null && $control->shouldAbort());
};
$options = [
'should_abort' => $shouldAbort,
'upstream_user' => 'admin-diagnosis-assistant-' . $this->adminId,
'on_authorized' => function (array $metadata) use (&$control, &$emit, &$profile, &$sseStartedAt, $requestId, $timing): bool {
// prepareAssistant reaches this callback only after permission, task
// and canonical diagnosis DataScope checks. Failures before it are JSON.
$control = new AssistantRequestControl($this->adminId, (int) $metadata['diagnosis_id'], $requestId);
if (!$control->begin()) return false;
$profile = (string) $metadata['model_key'];
$emit = $this->openAssistantSse();
$sseStartedAt = hrtime(true);
$started = $emit('start', $metadata + ['request_id' => $requestId, 'message' => '已连接,正在整理资料']);
$timing->log('sse_start', ['status' => $started ? 'ok' : 'disconnected']);
return true;
},
'on_progress' => static function (array $progress) use (&$emit): void {
if ($emit !== null) $emit('progress', $progress);
},
'on_stop' => static function (bool $confirmed, string $taskId = '') use (&$control): void {
if ($control !== null && $taskId !== '') $control->markStop($confirmed, $taskId);
},
'on_transfer_end' => static function (string $taskId = '') use (&$control): void {
if ($control !== null) $control->clearTask($taskId !== '' ? $taskId : null);
},
'on_cancel_task' => static function (string $taskId, callable $performStop) use (&$control): array {
if ($control === null) return ['accepted' => false, 'pending' => false];
$control->signalCancel();
return $control->stopBoundTask($performStop, $taskId);
},
'on_terminal' => static function (string $taskId, string $status) use (&$control): void {
if ($control !== null) $control->markTerminal($taskId, $status);
},
];
$upstreamUser = $options['upstream_user'];
$options['on_task'] = static function (string $taskId) use (&$control, &$profile, $upstreamUser): void {
if ($control !== null) $control->bindTask($profile, $upstreamUser, $taskId);
};
try {
$prepared = DiagnosisAiLogic::prepareAssistant(
(int) $params['id'], (string) $params['task'], (string) ($params['prompt'] ?? ''),
$this->adminId, $this->adminInfo, $timing, $options
);
if ($emit === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
if ($prepared === null) {
$this->finishAssistantError($emit, $control, $shouldAbort());
exit;
}
$this->runAssistantSse($prepared, $timing, $emit, $shouldAbort, $options, $control, $sseStartedAt);
} catch (\Throwable $e) {
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode(['trace_id' => $timing->id()]));
$timing->log('sse_done', ['duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt), 'status' => 'exception']);
if ($emit === null) return $this->fail('AI 助手暂时不可用,请稍后重试');
try { if ($control !== null) $control->finish('error'); } catch (\Throwable $ignored) {}
$emit('error', ['code' => 'AI_ASSISTANT_FAILED', 'message' => 'AI 助手暂时不可用,请稍后重试']);
exit;
}
$this->runAssistantSse($prepared, $timing);
}
/** @param array<string,mixed> $prepared */
private function runAssistantSse(array $prepared, AssistantSseTiming $timing): void
public function cancelAiAssistant()
{
while (ob_get_level() > 0) {
ob_end_clean();
}
$params = (new DiagnosisValidate())->post()->goCheck('cancelAiAssistant');
$result = DiagnosisAiLogic::cancelAssistant((int) $params['id'], (string) $params['request_id'], $this->adminId, $this->adminInfo);
return $result === null ? $this->fail(DiagnosisAiLogic::getError()) : $this->data($result);
}
private function openAssistantSse(): callable
{
while (ob_get_level() > 0) ob_end_clean();
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
ignore_user_abort(true);
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
if (function_exists('apache_setenv')) @apache_setenv('no-gzip', '1');
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
header('Content-Encoding: none');
echo ':' . str_repeat(' ', 2048) . "\n\n";
$this->flushSseOutput();
$protocol = new AssistantSseProtocol();
$emit = function (string $event, array $payload) use ($protocol): bool {
if ($protocol->isTerminal() || connection_aborted()) {
return false;
}
return function (string $event, array $payload) use ($protocol): bool {
if ($protocol->isTerminal() || connection_aborted()) return false;
$encoded = $protocol->encode($event, $payload);
if ($encoded === null) {
return false;
}
if ($encoded === null) return false;
echo $encoded;
$this->flushSseOutput();
return !connection_aborted();
};
}
$startEmitted = $emit('start', [
'task' => (string) ($prepared['task'] ?? ''),
'model_key' => (string) ($prepared['profile'] ?? ''),
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
'context_scope' => (string) ($prepared['context_scope'] ?? ''),
'context_version' => (string) ($prepared['context_version'] ?? ''),
'source_summary' => is_array($prepared['source_summary'] ?? null)
? $prepared['source_summary']
: [],
'message' => '已连接,正在生成…',
]);
$sseStartedAt = hrtime(true);
private function finishAssistantError(callable $emit, ?AssistantRequestControl $control, bool $cancelled): void
{
if ($control !== null) $control->finish($cancelled ? 'cancelled' : 'error');
if ($cancelled) {
$emit('progress', ['phase' => 'cancelled', 'status' => 'cancelled', 'message' => '本次请求已取消']
+ ($control !== null ? $control->publicState() : []));
}
$emit('error', ['code' => $cancelled ? 'AI_ASSISTANT_CANCELLED' : DiagnosisAiLogic::getAssistantErrorCode(),
'message' => $cancelled ? '本次请求已取消' : DiagnosisAiLogic::getError()]);
}
/** @param array<string,mixed> $prepared */
private function runAssistantSse(array $prepared, AssistantSseTiming $timing, callable $emit, callable $shouldAbort,
array $options, AssistantRequestControl $control, int $sseStartedAt): void
{
$firstDeltaMs = null;
$timing->log('sse_start', ['status' => $startEmitted ? 'ok' : 'disconnected']);
try {
$result = DiagnosisAiLogic::streamPreparedAssistant(
$prepared,
static function (string $delta) use ($emit, $timing, $sseStartedAt, &$firstDeltaMs): bool {
$observedAtMs = $firstDeltaMs === null && $delta !== ''
? AssistantSseTiming::elapsedMs($sseStartedAt)
: null;
$accepted = $emit('delta', ['text' => $delta]);
if ($accepted && $observedAtMs !== null) {
$firstDeltaMs = $observedAtMs;
$timing->log('first_delta', [
'duration_ms' => $firstDeltaMs,
'delta_bytes' => strlen($delta),
]);
}
return $accepted;
},
static fn (): bool => connection_aborted() === 1,
$timing
);
if (connection_aborted()) {
$timing->log('sse_done', [
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
'first_delta_observed' => $firstDeltaMs !== null,
'first_delta_ms' => $firstDeltaMs,
'status' => 'disconnected',
]);
exit;
}
if ($result === null) {
$emit('error', [
'code' => DiagnosisAiLogic::getAssistantErrorCode(),
'message' => DiagnosisAiLogic::getError(),
]);
$result = DiagnosisAiLogic::streamPreparedAssistant(
$prepared,
static function (string $delta) use ($emit, $timing, $sseStartedAt, &$firstDeltaMs, $shouldAbort): bool {
if ($shouldAbort()) return false;
$observedAtMs = $firstDeltaMs === null && $delta !== '' ? AssistantSseTiming::elapsedMs($sseStartedAt) : null;
$accepted = $emit('delta', ['text' => $delta]);
if ($accepted && $observedAtMs !== null) {
$firstDeltaMs = $observedAtMs;
$timing->log('first_delta', ['duration_ms' => $firstDeltaMs, 'delta_bytes' => strlen($delta)]);
}
return $accepted;
},
$shouldAbort, $timing, $options
);
$cancelled = $shouldAbort();
if ($result === null || $cancelled) {
$this->finishAssistantError($emit, $control, $cancelled);
} else {
$control->finish('done');
if ($control->publicState()['status'] === 'cancelled') {
$this->finishAssistantError($emit, $control, true);
} else {
$emit('done', $result);
}
$timing->log('sse_done', [
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
'first_delta_observed' => $firstDeltaMs !== null,
'first_delta_ms' => $firstDeltaMs,
'status' => $result === null ? 'error' : 'ok',
]);
} catch (\Throwable $e) {
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([
'trace_id' => $timing->id(),
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
$timing->log('sse_done', [
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
'first_delta_observed' => $firstDeltaMs !== null,
'first_delta_ms' => $firstDeltaMs,
'status' => 'exception',
]);
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]);
}
$timing->log('sse_done', ['duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
'first_delta_observed' => $firstDeltaMs !== null, 'first_delta_ms' => $firstDeltaMs,
'status' => $cancelled ? 'cancelled' : ($result === null ? 'error' : 'ok')]);
exit;
}