Files
2026-07-22 10:18:59 +08:00

407 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\service;
use app\model\AiModel;
use think\exception\HttpResponseException;
/**
* Dify 应用 API 接入服务。
*
* Dify 使用的是自己的一套接口协议,跟 OpenAI 的 /chat/completions 完全不同:
* - 发送消息:POST {api_base_url}/chat-messages
* - 上传文件:POST {api_base_url}/files/upload
* - 会话上下文由 Dify 自己维护(通过 conversation_id 串联),不需要像 OpenAI 那样
* 每次把完整的历史消息数组传过去,只需要传当前这一句 query + 上一次返回的 conversation_id。
*
* 参考文档:https://docs.dify.ai/api-reference
*/
class DifyService
{
public static function chat(AiModel $model, string $query, array $files, ?string $conversationId, string $userId): array
{
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
$payload = self::buildPayload($query, $files, $conversationId, $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 = 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);
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,
'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0,
];
}
public static function streamChat(
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 后是 "[]"
// 但 DifyPydantic)要求 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/v1Dify 使用 /chat-messages 接口,不是 OpenAI 的 /chat/completions';
}
if ($httpCode === 401) {
return '(请确认 API Key 是在 Dify 应用"访问 API"页面获取的密钥,而不是账号登录密码或 OpenAI Key';
}
return '';
}
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 (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;
}
}