false, 'error_code' => 'AI_ASSISTANT_CANCELLED', 'error' => '合成取消']; if ($GLOBALS['uploadTransportScenario'] === 'upload-failed') return ['ok' => false, 'error_code' => 'REQUIRED_FILES_UPLOAD_FAILED', 'error' => '合成上传失败']; $result = []; foreach ($files as $index => $file) { foreach (['uploading', 'uploaded'] as $status) { if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])(['index' => $index + 1, 'total' => count($files), 'status' => $status]); } $result[] = ['type' => $file['type'], 'transfer_method' => 'local_file', 'upload_file_id' => 'fixture-upload-' . ($index + 1)]; } return ['ok' => true, 'files' => $result, 'attachment_transport' => 'uploaded_files']; } } function curl_init(): \stdClass { return new \stdClass(); } function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; } function curl_exec(\stdClass $handle): string|bool { $payload = json_decode($handle->options[CURLOPT_POSTFIELDS], true); $GLOBALS['uploadTransportRequests'][] = $payload; $stream = ($payload['response_mode'] ?? '') === 'streaming'; $handle->code = $GLOBALS['uploadTransportScenario'] === 'inputs' && !empty($payload['inputs']) ? 400 : 200; ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); if ($handle->code !== 200) { $body = json_encode(['code' => 'invalid_param', 'message' => 'synthetic_undeclared_input input variable invalid']); if ($stream) { ($handle->options[CURLOPT_WRITEFUNCTION])($handle, $body); return true; } return $body; } if (!$stream) return json_encode(['answer' => '合成回答']); $write = $handle->options[CURLOPT_WRITEFUNCTION]; $write($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => '合成回答']) . "\n\n"); $write($handle, "data: {\"event\":\"message_end\"}\n\n"); return true; } function curl_errno(\stdClass $handle): int { return 0; } function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code; } function curl_close(\stdClass $handle): void {} } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\common\service\DifyChatService; use app\adminapi\service\AiRequiredSource; function uploadTransportExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } $uploadTransportConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, 'required_attachment_transport' => 'upload', 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; $files = [['type' => 'image', 'url' => 'https://synthetic.invalid/one.png'], ['type' => 'image', 'url' => 'https://synthetic.invalid/two.png'], ['type' => 'document', 'url' => 'https://synthetic.invalid/three.pdf']]; $options = ['required_files' => true, 'knowledge_mode' => 'evidence', 'required_sections' => AiRequiredSource::manifest([])]; foreach ([false, true] as $stream) foreach (['success', 'inputs', 'upload-failed', 'cancel'] as $scenario) { $uploadTransportScenario = $scenario; $uploadTransportStages = []; $uploadTransportRequests = []; $events = []; $opts = $options + ['on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]; $result = $stream ? DifyChatService::streamChat('qwen', ['synthetic_undeclared_input' => 'value'], '合成资料', 'fixture-same-user', static fn (string $text): bool => true, null, $files, $opts) : DifyChatService::chat('qwen', ['synthetic_undeclared_input' => 'value'], '合成资料', 'fixture-same-user', $files, $opts); uploadTransportExpect(count($uploadTransportStages) === 1, 'all required files are staged once per request'); uploadTransportExpect($uploadTransportStages[0]['user'] === 'fixture-same-user' && count($uploadTransportStages[0]['files']) === 3, 'upload and chat share end user and complete source set'); if (in_array($scenario, ['upload-failed', 'cancel'], true)) { uploadTransportExpect(empty($result['ok']) && $uploadTransportRequests === [], 'failed or cancelled upload never starts model generation'); uploadTransportExpect($result['error_code'] === ($scenario === 'cancel' ? 'AI_ASSISTANT_CANCELLED' : 'REQUIRED_FILES_UPLOAD_FAILED'), 'upload failure retains actual cause'); continue; } uploadTransportExpect(!empty($result['ok']) && $result['attachment_transfer'] === 'uploaded_files', 'final receipt identifies actual uploads'); uploadTransportExpect(count($uploadTransportRequests) === ($scenario === 'inputs' ? 2 : 1), 'bounded input compatibility remains intact'); foreach ($uploadTransportRequests as $payload) { uploadTransportExpect($payload['user'] === 'fixture-same-user' && count($payload['files']) === 3, 'every generation attempt retains all uploaded files'); uploadTransportExpect(array_column($payload['files'], 'transfer_method') === ['local_file', 'local_file', 'local_file'], 'Dify gets upload IDs, not resource URLs'); uploadTransportExpect(!array_key_exists('url', $payload['files'][0]) && $payload['files'][0]['upload_file_id'] === 'fixture-upload-1', 'original URL never leaks into uploaded wire files'); } uploadTransportExpect(count(array_filter($events, static fn (array $event): bool => ($event['status'] ?? '') === 'uploading')) >= 3, 'safe per-file upload progress reaches the desktop'); } echo "Dify required upload transport / complete IDs / same user / input retry reuse / failure / cancel: OK\n"; }