This commit is contained in:
Your Name
2026-07-22 10:18:59 +08:00
parent 2530ddada6
commit 0fb03d0bca
618 changed files with 19445 additions and 3 deletions
+406
View File
@@ -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 后是 "[]"
// 但 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;
}
}