更新
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
<?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 后是 "[]",
|
||||
// 但 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 '';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user