This commit is contained in:
Your Name
2026-09-10 15:19:17 +08:00
parent 27fbef9321
commit 36975c6c1b
487 changed files with 15696 additions and 78 deletions
+54 -9
View File
@@ -38,7 +38,8 @@ class DifyChatService
array $inputs,
string $query,
string $user,
array $files = []
array $files = [],
array $options = []
): array
{
$config = config('prescription_ai') ?: [];
@@ -61,7 +62,9 @@ class DifyChatService
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
}
$timeout = (int) ($config['timeout'] ?? 0);
// A caller may raise the single-request budget for staged background analysis. It stays
// bounded by MAX_TIMEOUT; interactive callers keep the configured default.
$timeout = (int) ($options['timeout'] ?? $config['timeout'] ?? 0);
if (!self::isValidTimeout($timeout)) {
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
}
@@ -74,11 +77,18 @@ class DifyChatService
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$normalized = self::normalizeFiles($files, self::maxFiles($config));
$strictFiles = !empty($options['strict_files']);
$normalized = self::normalizeFiles($files, self::maxFiles($config, $modelConfig), !$strictFiles);
if ($strictFiles && !self::strictFilesComplete($files, $normalized)) {
return self::error('STRICT_FILES_INVALID_OR_LIMIT', '附件无效或超出单批上限,须分批处理');
}
$startedAt = microtime(true);
$formatted = null;
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
$attemptPlan = $strictFiles
? [['files' => $normalized['kept'], 'omitted' => []]]
: self::buildAttemptPlan($normalized['kept'], $normalized['dropped']);
foreach ($attemptPlan as $attempt) {
$fileRejected = false;
$inputRejected = false;
@@ -100,6 +110,11 @@ class DifyChatService
$lastSpec = [];
foreach ($requestSpecs as $index => $requestSpec) {
// The legacy compatible path cannot transmit documents. Strict callers must
// receive an explicit gap, never a successful text-only fallback.
if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], $attempt['files'])) {
return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件');
}
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
@@ -142,6 +157,10 @@ class DifyChatService
$formatted = self::formatResponse($lastResponse, $startedAt);
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
if (!empty($formatted['ok'])) {
if ($strictFiles) {
$formatted['transmitted_file_count'] = count($attempt['files']);
$formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? '');
}
return $formatted;
}
// Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整
@@ -210,7 +229,7 @@ class DifyChatService
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$normalized = self::normalizeFiles($files, self::maxFiles($config));
$normalized = self::normalizeFiles($files, self::maxFiles($config, $modelConfig));
$startedAt = microtime(true);
$formatted = null;
@@ -422,6 +441,16 @@ class DifyChatService
));
}
private static function strictFilesComplete(array $requested, array $normalized): bool
{
return count($normalized['kept']) === count($requested) && $normalized['dropped'] === [];
}
private static function strictProtocolSupportsFiles(string $protocol, array $files): bool
{
return $protocol === 'dify' || ($protocol === 'openai' && self::nonImageFiles($files) === []);
}
/**
* 清洗附件,并按上游应用允许的数量截断。
*
@@ -430,6 +459,7 @@ class DifyChatService
* 录像可能几十份),因此这里必须主动截断;被截断的附件不会被悄悄丢弃,
* 而是以清单形式随提示词送达,让模型知道存在哪些它读不到的资料。
* 保持调用方给定的顺序,由调用方决定哪些附件最值得送上去。
* 严格批次关闭 URL 去重,保留同一地址对应的每个逻辑附件及其清单位置。
*
* @param array<int,mixed> $files
* @return array{
@@ -437,7 +467,7 @@ class DifyChatService
* dropped:array<int,array{type:string,transfer_method:string,url:string}>
* }
*/
private static function normalizeFiles(array $files, int $maxFiles): array
private static function normalizeFiles(array $files, int $maxFiles, bool $deduplicate = true): array
{
$maxFiles = max(0, $maxFiles);
$kept = [];
@@ -451,7 +481,7 @@ class DifyChatService
$url = trim((string) ($file['url'] ?? ''));
if (!in_array($type, ['image', 'document', 'audio', 'video', 'custom'], true)
|| !self::isValidRemoteFileUrl($url)
|| isset($seen[$url])) {
|| ($deduplicate && isset($seen[$url]))) {
continue;
}
$seen[$url] = true;
@@ -470,9 +500,11 @@ class DifyChatService
}
/** @param array<string,mixed> $config */
private static function maxFiles(array $config): int
/** Each application declares its own file_upload.number_limits; the global value is the fallback. */
private static function maxFiles(array $config, array $modelConfig = []): int
{
$configured = (int) ($config['max_files'] ?? self::DEFAULT_MAX_FILES);
$configured = array_key_exists('max_files', $modelConfig)
? (int) $modelConfig['max_files'] : (int) ($config['max_files'] ?? self::DEFAULT_MAX_FILES);
return $configured >= 0 ? $configured : self::DEFAULT_MAX_FILES;
}
@@ -1162,9 +1194,22 @@ class DifyChatService
'content' => $answer,
'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''),
'latency_ms' => $latencyMs,
'model_name' => is_string($decoded['model'] ?? null) ? $decoded['model'] : null,
'usage' => self::normalizedUsage($decoded['usage'] ?? $decoded['metadata']['usage'] ?? null),
];
}
/** Missing provider usage remains unknown, never a fabricated zero-token charge. */
private static function normalizedUsage($usage): array
{
$result = [];
foreach (['prompt_tokens', 'completion_tokens', 'total_tokens'] as $key) {
$value = is_array($usage) ? ($usage[$key] ?? null) : null;
$result[$key] = is_numeric($value) && (float) $value >= 0 ? (int) $value : null;
}
return $result;
}
/** @param array<string,mixed> $decoded */
private static function extractContent(array $decoded): string
{