122 lines
10 KiB
PHP
122 lines
10 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace think\facade {
|
|
class Log { public static function warning(string $message): void {} public static function write(string $message, string $level): void {} }
|
|
}
|
|
namespace app\common\service {
|
|
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
|
|
function config(string $name): array { return $GLOBALS['uploadTransportConfig']; }
|
|
final class DifyRequiredAttachmentUpload {
|
|
public static function prepare(string $endpoint, string $key, string $user, array $files, int $timeout,
|
|
?callable $shouldAbort = null, array $options = []): array {
|
|
$GLOBALS['uploadTransportStages'][] = compact('endpoint', 'user', 'files', 'timeout');
|
|
if ($GLOBALS['uploadTransportScenario'] === 'cancel') return ['ok' => 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";
|
|
}
|