Files
zyt/server/app/common/service/DifyChatService.php
T
2026-08-18 14:08:38 +08:00

796 lines
28 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 处方/诊单 AI 上游客户端。
*
* 兼容 Dify blocking chat-messages 与 OpenAI-compatible chat completions。
* 地址和凭据只从服务端 prescription_ai 配置读取,不进入响应、日志或请求正文。
*/
class DifyChatService
{
/** @var array<int,string> */
private const ALLOWED_PROFILES = ['qwen', 'openai'];
private const MIN_TIMEOUT = 1;
private const MAX_TIMEOUT = 300;
/**
* @param array<string,mixed> $inputs
* @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string}
*/
public static function chat(string $profile, array $inputs, string $query, string $user): array
{
$config = config('prescription_ai') ?: [];
if (empty($config['enable'])) {
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
}
$modelConfig = self::resolveProfileConfig($config, $profile);
if ($modelConfig === null) {
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
}
$baseUrl = trim((string) ($config['base_url'] ?? ''));
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
$apiKey = trim($rawApiKey);
if ($baseUrl === '' || $apiKey === '') {
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
}
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
}
$timeout = (int) ($config['timeout'] ?? 0);
if (!self::isValidTimeout($timeout)) {
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
}
if (!function_exists('curl_init')) {
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
}
$model = trim((string) ($modelConfig['name'] ?? ''));
if ($model === '') {
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$requestSpecs = self::buildRequestSpecs($baseUrl, $model, $inputs, $query, $user);
$startedAt = microtime(true);
$lastResponse = null;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendRequest(
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout
);
$lastResponse = $response;
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
// 避免因业务参数错误而重复提交同一份临床数据。
$hasFallback = isset($requestSpecs[$index + 1]);
if ($hasFallback && in_array($response['http_code'], [404, 405], true)) {
continue;
}
return self::formatResponse($response, $startedAt);
}
return self::formatResponse($lastResponse ?? [
'body' => '',
'errno' => 0,
'http_code' => 0,
], $startedAt);
}
/**
* 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。
*
* @param array<string,mixed> $inputs
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
public static function streamChat(
string $profile,
array $inputs,
string $query,
string $user,
callable $onDelta,
?callable $shouldAbort = null
): array {
$config = config('prescription_ai') ?: [];
if (empty($config['enable'])) {
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
}
$modelConfig = self::resolveProfileConfig($config, $profile);
if ($modelConfig === null) {
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
}
$baseUrl = trim((string) ($config['base_url'] ?? ''));
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
$apiKey = trim($rawApiKey);
if ($baseUrl === '' || $apiKey === '') {
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
}
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
}
$timeout = (int) ($config['timeout'] ?? 0);
if (!self::isValidTimeout($timeout)) {
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
}
if (!function_exists('curl_init')) {
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
}
$model = trim((string) ($modelConfig['name'] ?? ''));
if ($model === '') {
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$requestSpecs = self::buildRequestSpecs(
$baseUrl,
$model,
$inputs,
$query,
$user,
true
);
$startedAt = microtime(true);
$lastResponse = null;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendStreamRequest(
$requestSpec['protocol'],
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout,
$onDelta,
$shouldAbort
);
$lastResponse = $response;
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
$hasFallback = isset($requestSpecs[$index + 1]);
if (
$hasFallback
&& empty($response['emitted'])
&& in_array($response['http_code'], [404, 405], true)
) {
continue;
}
return self::formatStreamResponse($response, $startedAt);
}
return self::formatStreamResponse($lastResponse ?? [
'errno' => 0,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], $startedAt);
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>|null
*/
private static function resolveProfileConfig(array $config, string $profile): ?array
{
if (!in_array($profile, self::ALLOWED_PROFILES, true)) {
return null;
}
$modelConfig = $config['models'][$profile] ?? null;
return is_array($modelConfig) ? $modelConfig : null;
}
/**
* @param array<string,mixed> $inputs
* @return array<int,array{protocol:string,url:string,payload:array<string,mixed>}>
*/
private static function buildRequestSpecs(
string $baseUrl,
string $model,
array $inputs,
string $query,
string $user,
bool $streaming = false
): array {
$baseUrl = rtrim($baseUrl, '/');
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
$difySpec = [
'protocol' => 'dify',
'url' => self::buildEndpoint($baseUrl, 'chat-messages'),
'payload' => [
'inputs' => $inputs,
'query' => $query,
'response_mode' => $streaming ? 'streaming' : 'blocking',
'user' => $user,
],
];
$openAiSpec = [
'protocol' => 'openai',
'url' => self::buildEndpoint($baseUrl, 'chat/completions'),
'payload' => [
'model' => $model,
'messages' => [
['role' => 'user', 'content' => $query],
],
'stream' => $streaming,
],
];
if (!$streaming) {
unset($openAiSpec['payload']['stream']);
}
if (str_ends_with($path, '/chat-messages')) {
return [$difySpec];
}
if (str_ends_with($path, '/chat/completions')) {
return [$openAiSpec];
}
// 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。
return [$difySpec, $openAiSpec];
}
private static function buildEndpoint(string $baseUrl, string $endpoint): string
{
$baseUrl = rtrim($baseUrl, '/');
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
if (str_ends_with($path, '/chat-messages') || str_ends_with($path, '/chat/completions')) {
return $baseUrl;
}
if (str_ends_with($path, '/v1')) {
return $baseUrl . '/' . $endpoint;
}
return $baseUrl . '/v1/' . $endpoint;
}
private static function isValidBaseUrl(string $baseUrl): bool
{
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
return false;
}
$parts = parse_url($baseUrl);
if (!is_array($parts)) {
return false;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
return in_array($scheme, ['http', 'https'], true)
&& trim((string) ($parts['host'] ?? '')) !== ''
&& !isset($parts['user'])
&& !isset($parts['pass'])
&& !isset($parts['query'])
&& !isset($parts['fragment']);
}
private static function isValidTimeout(int $timeout): bool
{
return $timeout >= self::MIN_TIMEOUT && $timeout <= self::MAX_TIMEOUT;
}
/**
* @param array<string,mixed> $payload
* @return array{body:string,errno:int,http_code:int}
*/
private static function sendRequest(
string $url,
array $payload,
string $apiKey,
int $timeout
): array {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($body === false) {
return ['body' => '', 'errno' => -1, 'http_code' => 0];
}
$ch = curl_init();
if ($ch === false) {
return ['body' => '', 'errno' => -2, 'http_code' => 0];
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$responseBody = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'body' => is_string($responseBody) ? $responseBody : '',
'errno' => $errno,
'http_code' => $httpCode,
];
}
/**
* @param array<string,mixed> $payload
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function sendStreamRequest(
string $protocol,
string $url,
array $payload,
string $apiKey,
int $timeout,
callable $onDelta,
?callable $shouldAbort
): array {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($body === false) {
return self::emptyStreamResponse(-1);
}
$ch = curl_init();
if ($ch === false) {
return self::emptyStreamResponse(-2);
}
$buffer = '';
$state = self::newStreamState();
$responseCode = 0;
$header = static function ($handle, string $line) use (&$responseCode): int {
if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) {
$responseCode = (int) $matches[1];
}
return strlen($line);
};
$write = static function ($handle, string $chunk) use (
$protocol,
&$buffer,
&$state,
&$responseCode,
$onDelta,
$shouldAbort
): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 0;
}
if ($responseCode < 200 || $responseCode >= 300) {
// Never decode or forward an error response body. Besides preventing
// leakage, this keeps 404/405 protocol fallback side-effect free.
return strlen($chunk);
}
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
return $state['callback_error'] ? 0 : strlen($chunk);
};
$progress = static function () use (&$state, $shouldAbort): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 1;
}
return 0;
};
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: text/event-stream',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_HEADERFUNCTION => $header,
CURLOPT_WRITEFUNCTION => $write,
CURLOPT_NOPROGRESS => false,
CURLOPT_XFERINFOFUNCTION => $progress,
]);
curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$state['client_aborted'] && !$state['callback_error']) {
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
}
return [
'errno' => $errno,
'http_code' => $httpCode,
'content' => $state['content'],
'message_id' => $state['message_id'],
'emitted' => $state['emitted'],
'upstream_error' => $state['upstream_error'],
'client_aborted' => $state['client_aborted'],
'callback_error' => $state['callback_error'],
'finished' => $state['finished'],
];
}
/**
* @return array{
* content:string,message_id:string,emitted:bool,upstream_error:bool,
* client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function newStreamState(): array
{
return [
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
];
}
/**
* 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。
*
* @param array<string,mixed> $state
* @param callable(string):mixed $onDelta
*/
private static function consumeStreamBytes(
string $protocol,
string &$buffer,
string $chunk,
array &$state,
callable $onDelta,
bool $final = false
): void {
$buffer .= $chunk;
while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
$delimiter = $match[0][0];
$offset = $match[0][1];
$frame = substr($buffer, 0, $offset);
$buffer = (string) substr($buffer, $offset + strlen($delimiter));
self::consumeStreamFrame($protocol, $frame, $state, $onDelta);
}
if ($final && trim($buffer) !== '') {
self::consumeStreamFrame($protocol, $buffer, $state, $onDelta);
$buffer = '';
}
}
/**
* @param array<string,mixed> $state
* @param callable(string):mixed $onDelta
*/
private static function consumeStreamFrame(
string $protocol,
string $frame,
array &$state,
callable $onDelta
): void {
if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) {
return;
}
$dataLines = [];
foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) {
if ($line === '' || str_starts_with($line, ':')) {
continue;
}
if (str_starts_with($line, 'data:')) {
$dataLines[] = ltrim(substr($line, 5), ' ');
}
}
if ($dataLines === []) {
return;
}
$data = implode("\n", $dataLines);
if ($data === '[DONE]') {
$state['finished'] = true;
return;
}
$decoded = json_decode($data, true);
if (!is_array($decoded)) {
return;
}
$delta = '';
if ($protocol === 'dify') {
$event = strtolower((string) ($decoded['event'] ?? ''));
if ($event === 'message_end') {
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
$state['finished'] = true;
return;
}
if ($event === 'error') {
$state['upstream_error'] = true;
return;
}
if (!in_array($event, ['message', 'agent_message'], true)) {
return;
}
$delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : '';
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
} else {
$delta = self::extractStreamDelta($decoded);
$state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']);
}
if ($delta === '') {
return;
}
try {
$accepted = $onDelta($delta);
if ($accepted === false) {
$state['callback_error'] = true;
return;
}
} catch (\Throwable $e) {
$state['callback_error'] = true;
return;
}
$state['content'] .= $delta;
$state['emitted'] = true;
}
/** @param array<string,mixed> $decoded */
private static function extractStreamDelta(array $decoded): string
{
$content = $decoded['choices'][0]['delta']['content'] ?? '';
if (is_string($content)) {
return $content;
}
if (!is_array($content)) {
return '';
}
$parts = [];
foreach ($content as $part) {
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
$parts[] = $part['text'];
}
}
return implode('', $parts);
}
/**
* 纯解析测试入口:生产流与测试使用同一逐字节解码路径。
*
* @param array<int,string> $chunks
* @return array{content:string,deltas:array<int,string>,message_id:string,finished:bool,upstream_error:bool}
*/
private static function decodeStreamChunks(string $protocol, array $chunks): array
{
$buffer = '';
$state = self::newStreamState();
$deltas = [];
$onDelta = static function (string $delta) use (&$deltas): void {
$deltas[] = $delta;
};
foreach ($chunks as $chunk) {
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
}
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
return [
'content' => $state['content'],
'deltas' => $deltas,
'message_id' => $state['message_id'],
'finished' => $state['finished'],
'upstream_error' => $state['upstream_error'],
];
}
/**
* @return array{
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function emptyStreamResponse(int $errno): array
{
return [
'errno' => $errno,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
];
}
/**
* @param array<string,mixed> $response
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
private static function formatStreamResponse(array $response, float $startedAt): array
{
$latencyMs = self::elapsedMilliseconds($startedAt);
if (!empty($response['client_aborted'])) {
return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs);
}
if (!empty($response['callback_error'])) {
return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs);
}
$errno = (int) ($response['errno'] ?? 0);
if ($errno !== 0) {
if ($errno === CURLE_OPERATION_TIMEDOUT) {
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
}
if ($errno === -1) {
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
}
if ($errno === -2) {
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
}
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
}
$httpCode = (int) ($response['http_code'] ?? 0);
if ($httpCode === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
}
if (empty($response['finished'])) {
return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs);
}
$content = (string) ($response['content'] ?? '');
if (trim($content) === '') {
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
}
return [
'ok' => true,
'content' => $content,
'message_id' => (string) ($response['message_id'] ?? ''),
'latency_ms' => $latencyMs,
];
}
/**
* @param array{body:string,errno:int,http_code:int} $response
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
private static function formatResponse(array $response, float $startedAt): array
{
$latencyMs = self::elapsedMilliseconds($startedAt);
$errno = $response['errno'];
$httpCode = $response['http_code'];
if ($errno !== 0) {
if ($errno === CURLE_OPERATION_TIMEDOUT) {
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
}
if ($errno === -1) {
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
}
if ($errno === -2) {
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
}
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
}
$decoded = json_decode($response['body'], true);
if ($httpCode === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400 || $httpCode < 200) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
}
if (!is_array($decoded)) {
return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs);
}
$answer = self::extractContent($decoded);
if ($answer === '') {
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
}
return [
'ok' => true,
'content' => $answer,
'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''),
'latency_ms' => $latencyMs,
];
}
/** @param array<string,mixed> $decoded */
private static function extractContent(array $decoded): string
{
$content = $decoded['answer'] ?? $decoded['choices'][0]['message']['content'] ?? '';
if (is_string($content)) {
return trim($content);
}
if (!is_array($content)) {
return '';
}
$parts = [];
foreach ($content as $part) {
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
$parts[] = $part['text'];
}
}
return trim(implode('', $parts));
}
private static function elapsedMilliseconds(float $startedAt): int
{
return (int) round((microtime(true) - $startedAt) * 1000);
}
/**
* @return array{ok:false,error_code:string,error:string,latency_ms:int}
*/
private static function error(string $code, string $message, int $latencyMs = 0): array
{
return [
'ok' => false,
'error_code' => $code,
'error' => $message,
'latency_ms' => $latencyMs,
];
}
}