api_base_url, '/') . '/chat-messages'; $conversationReset = false; for ($attempt = 0; $attempt < 2; $attempt++) { $requestConversationId = $attempt === 0 ? $conversationId : null; $payload = self::buildPayload($query, $files, $requestConversationId, $userId, false); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $model->api_key, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, ]); $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($response === false) { throw new HttpResponseException(json([ 'code' => 1, 'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'), 'data' => null, ], 502)); } if ($httpCode !== 200) { $detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode); if ( $attempt === 0 && $conversationId && self::isConversationNotFoundError($response . ' ' . $detail) ) { $conversationReset = true; continue; } throw new HttpResponseException(json([ 'code' => 1, 'message' => self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode)), 'data' => null, ], 502)); } $data = json_decode($response, true); if (!$data) { throw new HttpResponseException(json([ 'code' => 1, 'message' => 'Dify 响应解析失败', 'data' => null, ], 502)); } return [ 'answer' => $data['answer'] ?? '', 'conversation_id' => $data['conversation_id'] ?? null, 'conversation_reset' => $conversationReset, 'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0, ]; } throw new \RuntimeException('Dify 会话恢复失败'); } public static function streamChat( AiModel $model, string $query, array $files, ?string $conversationId, string $userId, callable $onChunk, callable $onDone, ?callable $onError = null, ?callable $onConversationReset = null ): void { $emittedContent = false; $attemptError = null; $attemptDone = null; $chunkProxy = function (string $delta) use (&$emittedContent, $onChunk) { $emittedContent = true; $onChunk($delta); }; $doneProxy = function (?string $newConversationId, int $tokens) use (&$attemptDone) { $attemptDone = [$newConversationId, $tokens]; }; $errorProxy = function (string $message) use (&$attemptError) { $attemptError = $message; }; self::streamChatAttempt( $model, $query, $files, $conversationId, $userId, $chunkProxy, $doneProxy, $errorProxy ); if ( $attemptDone === null && !$emittedContent && $conversationId && self::isConversationNotFoundError((string) $attemptError) ) { if ($onConversationReset) { $onConversationReset(); } $attemptError = null; self::streamChatAttempt( $model, $query, $files, null, $userId, $chunkProxy, $doneProxy, $errorProxy ); } if ($attemptDone !== null) { $onDone($attemptDone[0], $attemptDone[1]); return; } if ($onError && $attemptError !== null) { $onError($attemptError); } } private static function streamChatAttempt( AiModel $model, string $query, array $files, ?string $conversationId, string $userId, callable $onChunk, callable $onDone, ?callable $onError = null ): void { $url = rtrim($model->api_base_url, '/') . '/chat-messages'; $payload = self::buildPayload($query, $files, $conversationId, $userId, true); $errorBody = ''; $httpCode = 0; $finalConversationId = $conversationId; $finalTokens = 0; $eventError = null; $streamBuffer = ''; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $model->api_key, ], CURLOPT_RETURNTRANSFER => false, CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$httpCode) { if (preg_match('/^HTTP\/\d+\.\d+\s+(\d+)/', $header, $m)) { $httpCode = (int) $m[1]; } return strlen($header); }, CURLOPT_WRITEFUNCTION => function ($ch, $data) use ( &$errorBody, &$httpCode, $onChunk, &$finalConversationId, &$finalTokens, &$eventError, &$streamBuffer ) { if ($httpCode >= 400) { $errorBody .= $data; return strlen($data); } // cURL may split one SSE data line across arbitrary network chunks. $streamBuffer .= $data; $lines = preg_split('/\r?\n/', $streamBuffer) ?: []; $streamBuffer = (string) (array_pop($lines) ?? ''); foreach ($lines as $line) { $line = trim($line); if ($line === '' || !str_starts_with($line, 'data:')) { continue; } $json = json_decode(substr($line, 5), true); if (!$json) { continue; } $event = $json['event'] ?? ''; if (!empty($json['conversation_id'])) { $finalConversationId = $json['conversation_id']; } if ($event === 'error') { $eventError = $json['message'] ?? 'Dify 返回错误'; continue; } if (in_array($event, ['message', 'agent_message'], true)) { $delta = $json['answer'] ?? ''; if ($delta !== '') { $onChunk($delta); } } if ($event === 'message_end') { $finalTokens = $json['metadata']['usage']['total_tokens'] ?? $finalTokens; } } return strlen($data); }, CURLOPT_TIMEOUT => 120, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, ]); try { $result = curl_exec($ch); if ($result === false) { $message = 'Dify 请求失败: ' . curl_error($ch); $onError ? $onError($message) : null; return; } if ($httpCode >= 400) { $detail = self::parseErrorBody($errorBody) ?: ('HTTP ' . $httpCode); $onError ? $onError(self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode))) : null; return; } if ($eventError) { $onError ? $onError(self::humanizeError('Dify 请求失败: ' . $eventError)) : null; return; } } finally { curl_close($ch); } $onDone($finalConversationId, $finalTokens); } /** * 把本地已上传的文件转发上传到 Dify(Dify 需要自己的 upload_file_id 才能在 * chat-messages 里引用文件),失败时返回 null,调用方应做优雅降级处理。 */ public static function uploadFile(AiModel $model, string $path, string $mime, string $originalName, string $userId): ?string { [$fileId] = self::uploadFileWithDetail($model, $path, $mime, $originalName, $userId); return $fileId; } /** * 上传文件到 Dify,失败时返回 [null, errorMessage] */ public static function uploadFileWithDetail(AiModel $model, string $path, string $mime, string $originalName, string $userId): array { if (!is_file($path)) { return [null, '本地文件不存在']; } $url = rtrim($model->api_base_url, '/') . '/files/upload'; $safeName = self::safeUploadFilename($originalName, $path, $mime); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'file' => curl_file_create($path, $mime, $safeName), 'user' => $userId, ], CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $model->api_key, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, ]); $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($response === false) { return [null, '网络错误: ' . ($curlError ?: '无法连接 Dify')]; } if ($httpCode !== 200 && $httpCode !== 201) { $detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode); return [null, $detail]; } $data = json_decode($response, true); $fileId = $data['id'] ?? ($data['data']['id'] ?? null); return $fileId ? [$fileId, null] : [null, 'Dify 未返回 file_id']; } private static function safeUploadFilename(string $originalName, string $path, string $mime): string { $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION) ?: pathinfo($path, PATHINFO_EXTENSION)); if ($ext === '') { $ext = match (true) { str_starts_with($mime, 'image/png') => 'png', str_starts_with($mime, 'image/gif') => 'gif', str_starts_with($mime, 'image/webp') => 'webp', default => 'jpg', }; } return 'upload_' . uniqid('', true) . '.' . $ext; } /** * 测试 Dify 应用连接是否正常 */ public static function testConnection(array $config): array { $apiBaseUrl = rtrim($config['api_base_url'] ?? '', '/'); $apiKey = $config['api_key'] ?? ''; if (!$apiBaseUrl) { throw new \InvalidArgumentException('请填写 API 地址(Dify 应用的 API Base URL,如 https://api.dify.ai/v1)'); } if (!$apiKey) { throw new \InvalidArgumentException('请填写 API Key(在 Dify 应用的"访问 API"页面获取)'); } $url = $apiBaseUrl . '/chat-messages'; $payload = [ 'inputs' => new \stdClass(), 'query' => '请只回复:测试成功', 'response_mode' => 'blocking', 'conversation_id' => '', 'user' => 'connection-test', ]; $start = microtime(true); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, ]); $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); $latencyMs = (int) round((microtime(true) - $start) * 1000); if ($response === false) { throw new \RuntimeException('连接失败: ' . ($curlError ?: '网络不可达')); } if ($httpCode !== 200) { $detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode); throw new \RuntimeException('API 返回错误: ' . $detail . self::urlHint($httpCode)); } $data = json_decode($response, true); if (!$data) { throw new \RuntimeException('响应解析失败,请确认接口地址正确'); } $reply = $data['answer'] ?? ''; if ($reply === '') { throw new \RuntimeException('接口连接成功,但未返回有效内容'); } return [ 'success' => true, 'latency_ms' => $latencyMs, 'reply' => $reply, 'model' => 'dify', 'tokens' => $data['metadata']['usage']['total_tokens'] ?? null, ]; } private static function buildPayload(string $query, array $files, ?string $conversationId, string $userId, bool $streaming): array { $payload = [ // 注意:必须用 stdClass 而不是 [],PHP 的空数组 json_encode 后是 "[]", // 但 Dify(Pydantic)要求 inputs 必须是字典 "{}",否则会报 // "Input should be a valid dictionary" 的校验错误 'inputs' => new \stdClass(), 'query' => $query, 'response_mode' => $streaming ? 'streaming' : 'blocking', 'conversation_id' => $conversationId ?: '', 'user' => $userId, ]; if ($files) { $payload['files'] = $files; } return $payload; } private static function urlHint(int $httpCode): string { if ($httpCode === 404) { return '(请确认 API 地址填写的是 Dify 的 API 根路径,如 https://api.dify.ai/v1 或自部署的 http://your-host/v1,Dify 使用 /chat-messages 接口,不是 OpenAI 的 /chat/completions)'; } if ($httpCode === 401) { return '(请确认 API Key 是在 Dify 应用"访问 API"页面获取的密钥,而不是账号登录密码或 OpenAI Key)'; } return ''; } public static function isConversationNotFoundError(string $message): bool { $message = mb_strtolower($message); foreach ([ 'conversation not exists', 'conversation does not exist', 'conversation not found', 'conversation_not_exists', 'conversation_not_found', 'dify 会话已失效', ] as $needle) { if (str_contains($message, $needle)) { return true; } } return false; } private static function parseErrorBody(?string $body): ?string { if (!$body) { return null; } $json = json_decode($body, true); if (json_last_error() === JSON_ERROR_NONE) { return $json['message'] ?? null; } return trim($body) ?: null; } /** * 将 Dify 插件/模型层的英文错误转为可操作的中文提示 */ public static function humanizeError(string $message): string { if (self::isConversationNotFoundError($message)) { return 'Dify 会话已失效,系统创建新会话后仍未恢复,请稍后重新发送。'; } if (str_contains($message, "Unsupported chat content part type: 'file'") || str_contains($message, 'Unsupported chat content part type')) { return 'Dify 模型层仍不接受 file 类型。请确认 Dify 应用已开启文档上传,且 files.type 使用 document(不是 file)。' . ' 原始错误:' . mb_substr($message, 0, 180); } if (str_contains($message, 'PluginInvokeError')) { return 'Dify 插件调用失败,请检查 Dify 应用内模型供应商配置是否与图片输入兼容。' . ' 详情:' . mb_substr($message, 0, 300); } return $message; } }