false, 'error_code' => 'AI_ASSISTANT_CANCELLED', 'error' => '合成取消']; if ($GLOBALS['uploadTransportScenario'] === 'upload-failed') return ['ok' => false, 'error_code' => 'REQUIRED_FILES_UPLOAD_FAILED', 'error' => '合成上传失败']; $result = []; $indices = []; $skipped = []; foreach ($files as $index => $file) { $scenario = $GLOBALS['uploadTransportScenario']; $missing = $scenario === 'all-missing' || (in_array($scenario, ['missing-first', 'missing-inputs', 'failed-after-skip', 'invalid-skip-receipt'], true) && $index === 0) || ($scenario === 'missing-middle' && $index === 1); if ($missing) { $skipped[] = ['index' => $index + 1, 'kind' => $file['type'], 'reason' => 'not_found']; if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])(['index' => $index + 1, 'total' => count($files), 'status' => 'skipped', 'reason' => 'not_found']); continue; } if ($scenario === 'failed-after-skip') return ['ok' => false, 'error_code' => 'REQUIRED_FILES_UPLOAD_FAILED', 'error' => '合成上传失败', 'skipped' => $skipped]; 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)]; $indices[] = $index + 1; } if ($GLOBALS['uploadTransportScenario'] === 'invalid-skip-receipt') $indices[0] = 1; return ['ok' => true, 'files' => $result, 'uploaded_indices' => $indices, 'skipped' => $skipped, '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 = in_array($GLOBALS['uploadTransportScenario'], ['inputs', 'missing-inputs'], true) && !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', 'missing-first', 'missing-middle', 'all-missing', 'missing-inputs', 'failed-after-skip', 'invalid-skip-receipt'] 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', 'failed-after-skip', 'invalid-skip-receipt'], 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'); if ($scenario === 'failed-after-skip') uploadTransportExpect($result['items'][0]['status'] === 'skipped' && $result['items'][0]['reason'] === 'not_found' && $result['items'][1]['status'] === 'failed', 'a later real failure does not relabel a known nonexistent source'); continue; } uploadTransportExpect(!empty($result['ok']) && $result['attachment_transfer'] === 'uploaded_files', 'final receipt identifies actual uploads'); $missingCount = $scenario === 'all-missing' ? 3 : (in_array($scenario, ['missing-first', 'missing-middle', 'missing-inputs'], true) ? 1 : 0); uploadTransportExpect(count($uploadTransportRequests) === (in_array($scenario, ['inputs', 'missing-inputs'], true) ? 2 : 1), 'bounded input compatibility remains intact'); uploadTransportExpect($result['submitted_attachment_count'] === 3 - $missingCount && $result['skipped_attachment_count'] === $missingCount && $result['attachment_count'] === 3 && count($result['items']) === 3 && $result['transmitted_file_count'] === 3 - $missingCount, 'final manifest distinguishes original, submitted and skipped counts'); uploadTransportExpect(count($result['required_sections']) === 13 && array_unique(array_column($result['required_sections'], 'status')) === ['submitted'], 'missing attachments do not block any of the thirteen clinical text sections'); foreach ($result['items'] as $item) { uploadTransportExpect(in_array($item['status'], ['submitted', 'skipped'], true), 'no false failed item on a completed response'); if ($item['status'] === 'skipped') uploadTransportExpect($item['reason'] === 'not_found', 'skip reason is explicit'); } foreach ($uploadTransportRequests as $payload) { uploadTransportExpect($payload['user'] === 'fixture-same-user' && count($payload['files']) === 3 - $missingCount, 'every generation attempt retains all available uploaded files'); uploadTransportExpect(array_column($payload['files'], 'transfer_method') === array_fill(0, 3 - $missingCount, 'local_file'), 'Dify gets upload IDs, not resource URLs'); foreach ($payload['files'] as $file) uploadTransportExpect(!array_key_exists('url', $file), 'original URLs never leak into uploaded wire files'); if ($missingCount > 0) uploadTransportExpect(substr_count($payload['query'], '【附件可用性说明】') === 1 && str_contains($payload['query'], '源文件不存在') && str_contains($payload['query'], '继续依据') && !str_contains($payload['query'], 'synthetic.invalid/'), 'model receives one safe missing-source notice and can continue'); } foreach ($events as $event) if (in_array($event['status'] ?? '', ['submitting', 'submitted'], true)) { uploadTransportExpect(count(array_filter($event['items'], static fn (array $item): bool => $item['status'] === 'skipped')) === $missingCount, 'transport notifications preserve every missing item across input retries'); } 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"; }