Merge branch 'master' of http://git.zhenyangtang.com.cn/ZYT/zyt
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user