initialize(); use app\controller\api\Chat; use app\model\AiModel; use app\service\AgentCatalog; use app\service\ComfyUIService; $failures = []; $checks = 0; $assert = static function (bool $condition, string $name, string $detail = '') use (&$failures, &$checks): void { $checks++; if ($condition) { printf("CHECK %02d PASS %s\n", $checks, $name); return; } $failures[] = [$name, $detail]; printf("CHECK %02d FAIL %s%s\n", $checks, $name, $detail === '' ? '' : ' - ' . $detail); }; $chatReflection = new ReflectionClass(Chat::class); $chat = $chatReflection->newInstanceWithoutConstructor(); $resolveTurn = $chatReflection->getMethod('resolveAgentImageTurn'); $resolveTurn->setAccessible(true); $buildAgentPrompt = $chatReflection->getMethod('buildAgentActionImagePrompt'); $buildAgentPrompt->setAccessible(true); $shouldRecoverAction = $chatReflection->getMethod('shouldRecoverAllowedAgentImageAction'); $shouldRecoverAction->setAccessible(true); $parseActionForTurn = $chatReflection->getMethod('parseAgentImageActionForTurn'); $parseActionForTurn->setAccessible(true); $takeSafeStreamPrefix = $chatReflection->getMethod('takeSafeAgentStreamPrefix'); $takeSafeStreamPrefix->setAccessible(true); $plainStream = 'Agent streaming text.'; $plainBuffer = $plainStream; $plainArgs = [&$plainBuffer, false]; $plainPrefix = $takeSafeStreamPrefix->invokeArgs($chat, $plainArgs); $assert($plainPrefix === $plainStream, 'Agent prose streams from its first chunk'); $assert($plainBuffer === '', 'Normal Agent prose has no fixed safety delay'); $plainFlushArgs = [&$plainBuffer, true]; $plainTail = $takeSafeStreamPrefix->invokeArgs($chat, $plainFlushArgs); $assert($plainBuffer === '', 'Agent stream flush clears its safety tail'); $assert($plainPrefix . $plainTail === $plainStream, 'Agent prose stream preserves every character'); $actionBuffer = str_repeat('Visible explanation. ', 5) . "```json\n{\"action\":\"generate_image\",\"prompt\":\"A cat\"}"; $actionArgs = [&$actionBuffer, false]; $visiblePrefix = $takeSafeStreamPrefix->invokeArgs($chat, $actionArgs); $assert(!str_contains($visiblePrefix, 'generate_image'), 'Internal image action never reaches streamed prose'); $assert(str_contains($actionBuffer, 'generate_image'), 'Internal image action remains buffered for routing'); $splitActionBuffer = '{"'; $splitArgs = [&$splitActionBuffer, false]; $splitPrefix = $takeSafeStreamPrefix->invokeArgs($chat, $splitArgs); $splitActionBuffer .= 'action":"generate_image","prompt":"A dog"}'; $splitArgs = [&$splitActionBuffer, false]; $splitSecondPrefix = $takeSafeStreamPrefix->invokeArgs($chat, $splitArgs); $assert( !str_contains($splitPrefix . $splitSecondPrefix, 'generate_image'), 'Image action split across chunks stays hidden' ); $assert(str_contains($splitActionBuffer, 'generate_image'), 'Split image action is retained intact'); $normalJsonBuffer = '{"answer":"ordinary JSON"}'; $normalJsonArgs = [&$normalJsonBuffer, false]; $normalJsonPrefix = $takeSafeStreamPrefix->invokeArgs($chat, $normalJsonArgs); $assert($normalJsonPrefix === '{"answer":"ordinary JSON"}', 'Ordinary JSON is not mistaken for an image action'); $assert($normalJsonBuffer === '', 'Ordinary JSON does not remain buffered'); $markdownActionBuffer = '```j'; $markdownFirstArgs = [&$markdownActionBuffer, false]; $markdownFirst = $takeSafeStreamPrefix->invokeArgs($chat, $markdownFirstArgs); $markdownActionBuffer .= "son\n{\"action\":\"generate_image\",\"prompt\":\"A fox\"}"; $markdownSecondArgs = [&$markdownActionBuffer, false]; $markdownSecond = $takeSafeStreamPrefix->invokeArgs($chat, $markdownSecondArgs); $assert($markdownFirst . $markdownSecond === '', 'Markdown image action split across chunks stays hidden'); $assert(str_contains($markdownActionBuffer, 'generate_image'), 'Markdown image action remains buffered for routing'); $image = [[ 'type' => 'image', 'url' => '/api/uploads/source.png', 'name' => 'source.png', 'mime' => 'image/png', ]]; $editHistory = [[ 'role' => 'user', 'content' => '把右下角水印去掉', 'attachments' => $image, ]]; $editTurn = $resolveTurn->invoke($chat, '把右下角水印去掉', $editHistory); $assert(($editTurn['allowed'] ?? false) === true, '上传原图去水印允许图片动作'); $assert(($editTurn['revision'] ?? false) === true, '上传原图去水印标记为修改'); $assert(($editTurn['uploaded_edit'] ?? false) === true, '上传原图使用本轮附件'); $assert(($editTurn['independent'] ?? true) === false, '图片编辑不作为独立新图'); $residualHistory = [ $editHistory[0], [ 'role' => 'assistant', 'content' => '已去除水印:', 'attachments' => $image, ], [ 'role' => 'user', 'content' => '去除的不彻底', 'attachments' => [], ], ]; $residualTurn = $resolveTurn->invoke($chat, '去除的不彻底', $residualHistory); $assert(($residualTurn['allowed'] ?? false) === true, '残留反馈允许继续执行图片处理'); $assert(($residualTurn['revision'] ?? false) === true, '残留反馈保持图片修改语义'); $authorTurn = $resolveTurn->invoke($chat, '把作者也给去除了', [ [ 'role' => 'assistant', 'content' => '已去除水印:', 'attachments' => $image, ], [ 'role' => 'user', 'content' => '把作者也给去除了', 'attachments' => [], ], ]); $assert(($authorTurn['allowed'] ?? false) === true, '连续作者删除允许图片动作'); $assert(($authorTurn['revision'] ?? false) === true, '连续作者删除保持上一张图片状态'); $enhanceTurn = $resolveTurn->invoke($chat, '变清晰', [ [ 'role' => 'assistant', 'content' => '已去除水印:', 'attachments' => $image, ], [ 'role' => 'user', 'content' => '变清晰', 'attachments' => [], ], ]); $assert(($enhanceTurn['allowed'] ?? false) === true, '短句变清晰允许图片增强'); $assert(($enhanceTurn['revision'] ?? false) === true, '短句变清晰继承当前图片'); $interruptedEnhanceTurn = $resolveTurn->invoke($chat, '可以帮我把图片变清晰吗', [ [ 'role' => 'assistant', 'content' => '已去除水印:', 'attachments' => $image, ], [ 'role' => 'user', 'content' => '变清晰', 'attachments' => [], ], [ 'role' => 'assistant', 'content' => '图片动作暂未执行。', 'attachments' => [], ], [ 'role' => 'user', 'content' => '可以帮我把图片变清晰吗', 'attachments' => [], ], ]); $assert(($interruptedEnhanceTurn['allowed'] ?? false) === true, '失败说明后仍保留最近有效图片'); $assert(($interruptedEnhanceTurn['revision'] ?? false) === true, '中断后的清晰化仍作为连续编辑'); $interruptedAuthorTurn = $resolveTurn->invoke($chat, '那把图片上的作者去除了', [ [ 'role' => 'assistant', 'content' => '已去除水印:', 'attachments' => $image, ], [ 'role' => 'user', 'content' => '变清晰', 'attachments' => [], ], [ 'role' => 'assistant', 'content' => '图片动作暂未执行。', 'attachments' => [], ], [ 'role' => 'user', 'content' => '可以帮我把图片变清晰吗', 'attachments' => [], ], [ 'role' => 'assistant', 'content' => '这里是被说明文字包裹的图片动作。', 'attachments' => [], ], [ 'role' => 'user', 'content' => '那把图片上的作者去除了', 'attachments' => [], ], ]); $assert(($interruptedAuthorTurn['allowed'] ?? false) === true, '多次失败后去作者仍允许图片动作'); $assert(($interruptedAuthorTurn['revision'] ?? false) === true, '多次失败后去作者仍继承最近图片'); $questionHistory = [[ 'role' => 'user', 'content' => '这张图里是谁', 'attachments' => $image, ]]; $questionTurn = $resolveTurn->invoke($chat, '这张图里是谁', $questionHistory); $assert(($questionTurn['allowed'] ?? true) === false, '图片问答不误触发编辑'); $assert(AgentCatalog::requestsImageEditing('换成夜晚背景'), '换背景识别为图片编辑'); $assert(AgentCatalog::requestsImageEditing('修复这张老照片'), '照片修复识别为图片编辑'); $assert(!AgentCatalog::requestsImageEditing('分析一下这张照片'), '图片分析保持文字任务'); $assert(AgentCatalog::imageEditMode('把水印擦掉') === 'inpaint', '局部移除选择 inpaint'); $assert(AgentCatalog::imageEditMode('改成油画风格') === 'img2img', '整体风格转换选择 img2img'); $assert(AgentCatalog::requestsWatermarkRemoval('帮我去除下水印'), '自然表达识别为去水印任务'); $assert(AgentCatalog::requestsWatermarkRemoval('去水印'), '最短口语去水印进入专用处理'); $assert(AgentCatalog::requestsImageEditing('去除的不彻底'), '省略式残留反馈识别为图片编辑'); $assert(AgentCatalog::requestsWatermarkRemoval('去除的不彻底'), '省略式残留反馈延续去水印任务'); $assert(AgentCatalog::imageEditMode('去除的不彻底') === 'inpaint', '残留反馈继续使用局部处理'); $assert(AgentCatalog::requestsImageEditing('把作者也给去除了'), '连续作者删除识别为图片编辑'); $assert(AgentCatalog::requestsImageEditing('变清晰'), '最短清晰化口语识别为图片编辑'); $assert(AgentCatalog::requestsImageEditing('可以帮我吧图片变清晰吗'), '自然清晰化问法识别为图片编辑'); $assert(!AgentCatalog::requestsImageEditing('怎么让图片变清晰'), '清晰化教程问题保持文字任务'); $assert(AgentCatalog::imageTextRemovalTarget('把作者也给去除了') === 'author', '连续操作提取作者删除目标'); $assert(AgentCatalog::imageTextRemovalTarget('标题也去掉') === 'title', '连续操作提取标题删除目标'); $assert(!AgentCatalog::requestsWatermarkRemoval('给图片加个水印'), '添加水印不会误判为移除'); $wrappedAction = '我会为你处理这张图片,下面是动作:' . str_repeat('说明文字', 90) . '{"action":"generate_image","prompt":"Remove only the lower-right watermark."}'; $parsedWrappedAction = AgentCatalog::parseImageAction($wrappedAction); $assert($parsedWrappedAction === null, '长解释包裹动作仍被严格解析器拒绝'); $routedWrappedAction = AgentCatalog::parseRoutedImageAction($wrappedAction); $assert(($routedWrappedAction['action'] ?? '') === 'generate_image', '路由确认后恢复说明文字中的动作'); $assert( ($parseActionForTurn->invoke($chat, $wrappedAction, true)['action'] ?? '') === 'generate_image', '控制器仅在允许图片操作时恢复包裹动作' ); $assert( $parseActionForTurn->invoke($chat, $wrappedAction, false) === null, '普通问答不会执行说明文字中的图片动作' ); $assert( $shouldRecoverAction->invoke($chat, true, $parsedWrappedAction) === true, '已确认图片路由会恢复不规范动作' ); $assert( $shouldRecoverAction->invoke($chat, false, $parsedWrappedAction) === false, '普通文字路由不会恢复图片动作' ); $editPrompt = $buildAgentPrompt->invoke( $chat, 1, 'Remove only the lower-right watermark from the uploaded book cover.', true ); $assert(str_contains($editPrompt, 'Preserve all existing text'), '编辑提示保留原图文字与排版'); $assert(!str_contains($editPrompt, 'No readable text'), '编辑提示不再全局禁止文字'); $generationPrompt = $buildAgentPrompt->invoke($chat, 1, 'A clean fantasy landscape.', false); $assert(str_contains($generationPrompt, 'No readable text'), '新图生成继续默认禁止文字'); $prepareEditPrompt = $chatReflection->getMethod('prepareComfyEditPrompt'); $prepareEditPrompt->setAccessible(true); $sanitizedWatermarkPrompt = $prepareEditPrompt->invoke( $chat, 'The lower-right corner contains a Baidu AI watermark.', ['operation' => 'remove_watermark'] ); $assert(!str_contains($sanitizedWatermarkPrompt, 'Baidu'), '去水印任务不会复述并重建原水印'); $assert(str_contains($sanitizedWatermarkPrompt, 'Do not recreate'), '去水印提示明确禁止重建水印'); $fixturePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'watermark_mask_fixture_' . bin2hex(random_bytes(6)) . '.png'; $fixture = imagecreatetruecolor(200, 300); $fixtureDark = imagecolorallocate($fixture, 20, 24, 30); $fixtureLight = imagecolorallocate($fixture, 220, 220, 220); imagefill($fixture, 0, 0, $fixtureDark); imagefilledrectangle($fixture, 150, 280, 195, 294, $fixtureLight); imagepng($fixture, $fixturePath); imagedestroy($fixture); $autoMaskPath = ComfyUIService::createAutomaticWatermarkMask($fixturePath, '帮我去掉水印'); $autoMask = imagecreatefrompng($autoMaskPath); $assert((imagecolorat($autoMask, 190, 290) & 0xFF) === 255, '无位置去水印自动覆盖右下角'); $assert((imagecolorat($autoMask, 10, 10) & 0xFF) === 0, '自动遮罩不修改画面主体'); imagedestroy($autoMask); $topLeftMaskPath = ComfyUIService::createAutomaticWatermarkMask($fixturePath, '去掉左上角水印'); $topLeftMask = imagecreatefrompng($topLeftMaskPath); $assert((imagecolorat($topLeftMask, 10, 10) & 0xFF) === 255, '明确位置时遮罩严格覆盖左上角'); $assert((imagecolorat($topLeftMask, 190, 290) & 0xFF) === 0, '明确位置不会误改右下角'); imagedestroy($topLeftMask); $fillServiceReflection = new ReflectionClass(ComfyUIService::class); $fillMaskedPixels = $fillServiceReflection->getMethod('fillMaskedPixelsFromBoundary'); $fillMaskedPixels->setAccessible(true); $fillFixture = imagecreatetruecolor(40, 40); $fillMask = imagecreatetruecolor(40, 40); $fillBase = imagecolorallocate($fillFixture, 28, 38, 52); $maskBlack = imagecolorallocate($fillMask, 0, 0, 0); $maskWhite = imagecolorallocate($fillMask, 255, 255, 255); imagefill($fillFixture, 0, 0, $fillBase); imagefill($fillMask, 0, 0, $maskBlack); imagefilledrectangle($fillMask, 14, 15, 26, 24, $maskWhite); $fillMaskedPixels->invoke(null, $fillFixture, $fillMask, 40, 40); $filledCenter = imagecolorat($fillFixture, 20, 20); $assert((($filledCenter >> 16) & 0xFF) < 80, '确定性填充不会生成高亮伪文字'); $assert(($filledCenter & 0xFF) > 20, '确定性填充延续周围背景颜色'); imagedestroy($fillFixture); imagedestroy($fillMask); $authorFixturePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'author_mask_fixture_' . bin2hex(random_bytes(6)) . '.png'; $authorFixture = imagecreatetruecolor(240, 320); for ($y = 0; $y < 320; $y++) { for ($x = 0; $x < 240; $x++) { $red = 24 + (int) round($x * 0.16); $green = 42 + (int) round($x * 0.12); $blue = 70 + (int) round($x * 0.1); imagesetpixel($authorFixture, $x, $y, ($red << 16) | ($green << 8) | $blue); } } $authorGold = imagecolorallocate($authorFixture, 230, 185, 92); imagefilledrectangle($authorFixture, 94, 112, 146, 122, $authorGold); imagepng($authorFixture, $authorFixturePath); imagedestroy($authorFixture); $authorMaskPath = ComfyUIService::createAutomaticTextRemovalMask($authorFixturePath, 'author'); $authorMask = imagecreatefrompng($authorMaskPath); $assert((imagecolorat($authorMask, 120, 117) & 0xFF) === 255, 'author mask detects the author credit'); $assert((imagecolorat($authorMask, 120, 75) & 0xFF) === 0, 'author mask does not touch the title band'); $authorResult = imagecreatefrompng($authorFixturePath); $authorOriginalFar = imagecolorat($authorResult, 20, 250); $fillTextBand = $fillServiceReflection->getMethod('fillTextBandFromHorizontalSurroundings'); $fillTextBand->setAccessible(true); $fillTextBand->invoke(null, $authorResult, $authorMask, 240, 320); $authorCenter = imagecolorat($authorResult, 120, 117); $assert((($authorCenter >> 16) & 0xFF) < 120, 'author fill removes bright text and its shadow band'); $assert(imagecolorat($authorResult, 20, 250) === $authorOriginalFar, 'author fill preserves pixels outside the local band'); imagedestroy($authorResult); imagedestroy($authorMask); @unlink($authorMaskPath); @unlink($authorFixturePath); $maskPixelValue = $fillServiceReflection->getMethod('maskPixelValue'); $maskPixelValue->setAccessible(true); $paletteMask = imagecreate(5, 5); $paletteBlack = imagecolorallocate($paletteMask, 0, 0, 0); $paletteWhite = imagecolorallocate($paletteMask, 255, 255, 255); imagefill($paletteMask, 0, 0, $paletteBlack); imagesetpixel($paletteMask, 2, 2, $paletteWhite); $assert($maskPixelValue->invoke(null, $paletteMask, 2, 2) === 255, '调色板遮罩正确读取白色修改区'); $assert($maskPixelValue->invoke(null, $paletteMask, 0, 0) === 0, '调色板遮罩正确读取黑色保护区'); imagedestroy($paletteMask); @unlink($autoMaskPath); @unlink($topLeftMaskPath); @unlink($fixturePath); $serviceReflection = new ReflectionClass(ComfyUIService::class); $enrichImagePrompt = $serviceReflection->getMethod('enrichUserPromptForImage'); $enrichImagePrompt->setAccessible(true); $standaloneImagePrompt = $enrichImagePrompt->invoke(null, '梦幻森林游戏概念原画'); $assert(str_contains($standaloneImagePrompt, 'standalone full-bleed finished image'), '游戏素材提示强制输出单张满幅成品'); $assert(str_contains($standaloneImagePrompt, 'Never visualize, quote, label, or print'), '游戏素材提示禁止把用户描述画进图片'); $requestedImageCount = $chatReflection->getMethod('requestedImageCount'); $requestedImageCount->setAccessible(true); $assert($requestedImageCount->invoke($chat, '制作小说封面') === 1, '未指定数量时默认只生成一张图'); $assert($requestedImageCount->invoke($chat, '请生成四张不同构图的小说封面') === 4, '中文数量可触发显式四图生成'); $assert($requestedImageCount->invoke($chat, '生成 2 个不同版本,比例 16:9') === 2, '阿拉伯数字数量不会被宽高比干扰'); $assert($requestedImageCount->invoke($chat, '给我十张方案') === 4, '显式批量数量按工作流上限收敛到四张'); $applyVisibleText = $chatReflection->getMethod('applyRequestedVisibleText'); $applyVisibleText->setAccessible(true); $exactTitlePrompt = $applyVisibleText->invoke( $chat, "A Chinese fantasy novel cover titled 'Divine Luo'. A golden deity above cloud mountains. No text, no letters, no Chinese characters, no title, no caption, no watermark.", '制作小说封面:神话大罗,我直通天地' ); $assert(str_contains($exactTitlePrompt, 'EXACT_VISIBLE_TEXT: <<<神话大罗,我直通天地>>>'), '明确封面标题保留原始中文逐字标记'); $assert(!str_contains($exactTitlePrompt, "titled 'Divine Luo'"), '明确中文标题移除 Agent 擅自翻译的英文标题'); $assert(!preg_match('/no\s+(?:readable\s+)?text/iu', $exactTitlePrompt), '明确标题任务移除冲突的全局禁字限制'); $exactAgentPrompt = AgentCatalog::buildImagePrompt(AgentCatalog::find('auto'), $exactTitlePrompt); $assert(!str_contains($exactAgentPrompt, 'no readable text'), 'Agent 画面方向不会向明确标题重新注入禁字规则'); $applyImageBatchSize = $serviceReflection->getMethod('applyImageBatchSize'); $applyImageBatchSize->setAccessible(true); $batchWorkflow = [ 'latent' => ['class_type' => 'EmptyLatentImage', 'inputs' => ['batch_size' => 1]], 'other' => ['class_type' => 'KSampler', 'inputs' => ['steps' => 20]], ]; $applyImageBatchSize->invokeArgs(null, [&$batchWorkflow, 4]); $assert(($batchWorkflow['latent']['inputs']['batch_size'] ?? null) === 4, '四图生成将 latent 批次数设为 4'); $assert(!array_key_exists('batch_size', $batchWorkflow['other']['inputs']), '四图生成不污染无批次字段的节点'); $applyImageBatchSize->invokeArgs(null, [&$batchWorkflow, 99]); $assert(($batchWorkflow['latent']['inputs']['batch_size'] ?? null) === 4, '四图生成批次数上限固定为 4'); $optimizeSampling = $serviceReflection->getMethod('optimizeTextToImageSampling'); $optimizeSampling->setAccessible(true); $legacyTurboWorkflow = [ 'loader' => ['class_type' => 'UNETLoaderMultiGPU', 'inputs' => ['unet_name' => 'z_image_turbo_bf16.safetensors']], 'sampler' => ['class_type' => 'KSampler', 'inputs' => ['steps' => 20, 'sampler_name' => 'euler', 'scheduler' => 'simple']], 'sampling' => ['class_type' => 'ModelSamplingAuraFlow', 'inputs' => ['shift' => 50]], ]; $optimizeSampling->invokeArgs(null, [&$legacyTurboWorkflow, []]); $assert(($legacyTurboWorkflow['sampler']['inputs']['steps'] ?? null) === 8, '旧版 Turbo 工作流自动降到八步采样'); $assert(($legacyTurboWorkflow['sampler']['inputs']['sampler_name'] ?? null) === 'res_multistep', 'Z-Image Turbo 自动使用匹配的采样器'); $assert(abs((float) ($legacyTurboWorkflow['sampling']['inputs']['shift'] ?? 0) - 5.0) < 0.0001, 'Z-Image Turbo 修正过高的 AuraFlow shift'); $promptSystem = $serviceReflection->getReflectionConstant('PROMPT_ENGINEER_SYSTEM')->getValue(); $assert(str_contains($promptSystem, 'title-safe negative space'), '小说封面提示保留后期排字安全区但不生成伪文字'); $assert(str_starts_with($promptSystem, '/no_think'), '提示词扩写显式关闭慢速思考链'); $assert(str_contains($promptSystem, 'under 80 English words'), '提示词扩写限制冗余长度'); $buildEditWorkflow = $serviceReflection->getMethod('buildEditWorkflow'); $buildEditWorkflow->setAccessible(true); $model = new AiModel(); $model->extra_config = []; $model->model_id = '1:1 (Square)'; $buildWorkflow = $serviceReflection->getMethod('buildWorkflow'); $buildWorkflow->setAccessible(true); $textToImageWorkflow = $buildWorkflow->invoke(null, '梦幻森林游戏场景素材', $model, 4); $cleanupNodes = array_filter( $textToImageWorkflow, fn (array $node): bool => ($node['class_type'] ?? '') === 'RegexReplace' && ($node['_meta']['title'] ?? '') === 'Strip prompt reasoning' ); $assert(count($cleanupNodes) === 1, '生图工作流在提示编码前插入思考内容清理节点'); $cleanupNodeId = (string) array_key_first($cleanupNodes); $linkedToCleanup = false; foreach ($textToImageWorkflow as $node) { if (($node['class_type'] ?? '') === 'CLIPTextEncode' && (($node['inputs']['text'][0] ?? '') === $cleanupNodeId)) { $linkedToCleanup = true; break; } } $assert($linkedToCleanup, '生图正向提示编码器只接收清理后的最终提示词'); $cleanupPattern = (string) ($cleanupNodes[$cleanupNodeId]['inputs']['regex_pattern'] ?? ''); $assert( preg_replace('/' . str_replace('/', '\\/', $cleanupPattern) . '/is', '', '用户原始描述final scene') === 'final scene', '提示清理节点会移除包含用户原文的 think 区块' ); $assert( preg_replace('/' . str_replace('/', '\\/', $cleanupPattern) . '/is', '', '未闭合的思考内容') === '', '提示清理节点不会把未闭合思考链送进图像编码器' ); $coverWorkflow = $buildWorkflow->invoke(null, '制作小说封面', $model, 1); $coverAspect = null; $coverSampler = null; $coverTextGenerator = null; foreach ($coverWorkflow as $node) { $type = $node['class_type'] ?? ''; if ($type === 'ResolutionSelector') { $coverAspect = $node['inputs']['aspect_ratio'] ?? null; } elseif ($type === 'KSampler') { $coverSampler = $node['inputs']; } elseif ($type === 'TextGenerate') { $coverTextGenerator = $node['inputs']; } } $assert($coverAspect === '2:3 (Portrait Photo)', '小说封面自动采用竖版构图比例'); $assert(($coverTextGenerator['max_length'] ?? 0) === 128, '提示词扩写限制长度以减少无效等待'); $assert(abs((float) ($coverTextGenerator['sampling_mode.temperature'] ?? 1) - 0.25) < 0.0001, '提示词扩写使用低温度提高指令稳定性'); $exactTextWorkflow = $buildWorkflow->invoke(null, $exactAgentPrompt, $model, 1); $exactPositive = ''; $exactNegative = ''; $exactRefine = null; foreach ($exactTextWorkflow as $node) { if (($node['class_type'] ?? '') === 'PrimitiveBoolean') { $exactRefine = $node['inputs']['value'] ?? null; } if (($node['class_type'] ?? '') !== 'CLIPTextEncode' || !is_string($node['inputs']['text'] ?? null)) { continue; } $title = (string) ($node['_meta']['title'] ?? ''); if (preg_match('/negative|负面|负向/i', $title)) { $exactNegative .= ' ' . $node['inputs']['text']; } else { $exactPositive .= ' ' . $node['inputs']['text']; } } $assert($exactRefine === false, '明确标题跳过可能改写原文的二次提示词扩写'); $assert(str_contains($exactPositive, '神话大罗,我直通天地'), '正向编码器收到完整准确中文标题'); $assert(str_contains($exactPositive, 'front-facing 2D fantasy key art'), '封面标题任务强制输出平面正封面而非书籍样机'); $assert(!str_contains($exactPositive, 'misspelled title'), '负面提示词不会再泄漏进正向编码器'); $exactTextNegativeBaseline = $serviceReflection->getReflectionConstant('EXACT_TEXT_NEGATIVE')->getValue(); $assert(str_contains($exactTextNegativeBaseline, 'misspelled title'), '明确标题使用专用错字负面提示'); $assert(str_contains($exactTextNegativeBaseline, 'book mockup'), '明确标题负面提示禁止书脊和立体样机产生额外文字'); $img2img = $buildEditWorkflow->invoke( null, 'make the source image warmer', $model, 'chat_edits/source.png', null, 'img2img' ); $img2imgTypes = array_column($img2img, 'class_type'); $assert(in_array('LoadImage', $img2imgTypes, true), 'img2img 包含 LoadImage'); $assert(in_array('VAEEncode', $img2imgTypes, true), 'img2img 包含 VAEEncode'); $imgSampler = null; foreach ($img2img as $node) { if (($node['class_type'] ?? '') === 'KSampler') { $imgSampler = $node; break; } } $imgLatentNode = (string) ($imgSampler['inputs']['latent_image'][0] ?? ''); $assert( $imgLatentNode !== '' && ($img2img[$imgLatentNode]['class_type'] ?? '') === 'VAEEncode', 'img2img 采样 latent 连接原图编码' ); $assert(abs((float) ($imgSampler['inputs']['denoise'] ?? 0) - 0.45) < 0.0001, 'img2img 默认 denoise 为 0.45'); $img2imgStrings = []; foreach ($img2img as $node) { foreach (($node['inputs'] ?? []) as $value) { if (is_string($value)) { $img2imgStrings[] = $value; } } } $img2imgText = implode("\n", $img2imgStrings); $assert(str_contains($img2imgText, 'Preserve all existing text'), 'img2img 工作流要求保留现有文字'); $editNegative = $serviceReflection->getReflectionConstant('EDIT_DEFAULT_NEGATIVE')->getValue(); $assert(str_contains($editNegative, 'altered existing typography'), '图片编辑负面提示保护原排版'); $buildDirectEditPrompt = $serviceReflection->getMethod('buildDirectEnglishEditPrompt'); $buildDirectEditPrompt->setAccessible(true); $directEditPrompt = $buildDirectEditPrompt->invoke( null, 'remove only the lower-right watermark', 'img2img' ); $assert(str_contains($directEditPrompt, 'Preserve all existing text'), '无扩写节点时仍保留现有文字'); $inpaint = $buildEditWorkflow->invoke( null, 'remove the marked watermark and restore the background', $model, 'chat_edits/source.png', 'chat_edits/mask.png', 'inpaint' ); $inpaintTypes = array_column($inpaint, 'class_type'); $assert(in_array('LoadImageMask', $inpaintTypes, true), 'inpaint 包含 LoadImageMask'); $assert(in_array('VAEEncodeForInpaint', $inpaintTypes, true), 'inpaint 包含 VAEEncodeForInpaint'); $inpaintSampler = null; foreach ($inpaint as $node) { if (($node['class_type'] ?? '') === 'KSampler') { $inpaintSampler = $node; break; } } $inpaintLatentNode = (string) ($inpaintSampler['inputs']['latent_image'][0] ?? ''); $assert( $inpaintLatentNode !== '' && ($inpaint[$inpaintLatentNode]['class_type'] ?? '') === 'VAEEncodeForInpaint', 'inpaint 采样 latent 连接局部重绘编码' ); $assert(abs((float) ($inpaintSampler['inputs']['denoise'] ?? 0) - 0.72) < 0.0001, 'inpaint 默认 denoise 为 0.72'); $watermarkInpaint = $buildEditWorkflow->invoke( null, 'remove the masked watermark', $model, 'chat_edits/source.png', 'chat_edits/mask.png', 'inpaint', 0.58 ); $watermarkSampler = null; foreach ($watermarkInpaint as $node) { if (($node['class_type'] ?? '') === 'KSampler') { $watermarkSampler = $node; break; } } $assert( abs((float) ($watermarkSampler['inputs']['denoise'] ?? 0) - 0.58) < 0.0001, '自动去水印使用受控局部重绘强度' ); $prepareEditPrompt = $chatReflection->getMethod('prepareComfyEditPrompt'); $prepareEditPrompt->setAccessible(true); $outpaintPrompt = $prepareEditPrompt->invoke( $chat, '扩展为 4:3', ['operation' => 'outpaint'] ); $assert(str_contains($outpaintPrompt, 'OUTPAINT_FULL_BLEED'), '扩图任务使用专用满幅补全提示'); $assert(!str_contains(strtolower($outpaintPrompt), 'mirror'), '扩图正向提示不再用禁用词反向激活镜像内容'); $assert(str_contains($outpaintPrompt, 'full-bleed'), '扩图提示要求无缝满幅画面'); $sceneAwareOutpaintPrompt = $prepareEditPrompt->invoke( $chat, '扩展为 4:3', [ 'operation' => 'outpaint', 'outpaint_scene_prompt' => 'A blue cosmic fantasy landscape with golden energy and floating rocks.', ] ); $assert( str_contains($sceneAwareOutpaintPrompt, 'blue cosmic fantasy landscape'), '扩图提示注入原图视觉场景,避免生成无关环境' ); $outpaintInpaint = $buildEditWorkflow->invoke( null, 'OUTPAINT_FULL_BLEED: extend the scene', $model, 'chat_edits/outpaint-source.png', 'chat_edits/outpaint-mask.png', 'inpaint', 1.0, 'outpaint', 135791113 ); $outpaintSampler = null; $outpaintModel = null; $outpaintFill = null; $outpaintBlur = null; $outpaintPatch = null; $outpaintConditioning = null; $outpaintComposite = null; $outpaintCompositeMask = null; foreach ($outpaintInpaint as $node) { if (($node['class_type'] ?? '') === 'KSampler') { $outpaintSampler = $node; } if (($node['class_type'] ?? '') === 'CheckpointLoaderSimple') $outpaintModel = $node; if (($node['class_type'] ?? '') === 'INPAINT_MaskedFill') $outpaintFill = $node; if (($node['class_type'] ?? '') === 'INPAINT_MaskedBlur') $outpaintBlur = $node; if (($node['class_type'] ?? '') === 'INPAINT_ApplyFooocusInpaint') $outpaintPatch = $node; if (($node['class_type'] ?? '') === 'INPAINT_VAEEncodeInpaintConditioning') $outpaintConditioning = $node; if (($node['class_type'] ?? '') === 'ImageCompositeMasked') { $outpaintComposite = $node; $outpaintCompositeMask = $node['inputs']['mask'] ?? null; } } $assert( abs((float) ($outpaintSampler['inputs']['denoise'] ?? 0) - 1.0) < 0.0001, '扩图边框完全重绘避免镜像残影' ); $assert( ((int) ($outpaintSampler['inputs']['seed'] ?? 0)) === 135791113, '扩图使用原图派生种子,不再让所有图片共用单一种子' ); $assert( ($outpaintModel['inputs']['ckpt_name'] ?? '') === 'juggernautXL_version6Rundiffusion.safetensors', '扩图使用 Fooocus 兼容的非蒸馏 SDXL 模型' ); $assert(($outpaintFill['inputs']['fill'] ?? '') === 'navier-stokes', '扩图区使用边界传播预填而非镜像像素'); $assert(((int) ($outpaintBlur['inputs']['blur'] ?? 0)) >= 65, '扩图区在采样前生成低频颜色引导'); $assert($outpaintPatch !== null, '扩图采样器应用 Fooocus inpaint patch'); $assert(isset($outpaintConditioning['inputs']['pixels'], $outpaintConditioning['inputs']['mask']), '扩图条件编码器接收预处理画布和蒙版'); $assert(isset($outpaintComposite['inputs']['mask']), '扩图输出恢复受保护的原图中心'); $compositeDestination = $outpaintComposite['inputs']['destination'] ?? null; $compositeBase = is_array($compositeDestination) ? ($outpaintInpaint[(string) ($compositeDestination[0] ?? '')] ?? null) : null; $assert( ($compositeBase['class_type'] ?? '') === 'LoadImage', '扩图最终合成以原图为中心保护底图' ); $finalMask = is_array($outpaintCompositeMask) ? ($outpaintInpaint[(string) ($outpaintCompositeMask[0] ?? '')] ?? null) : null; $assert( ($finalMask['class_type'] ?? '') === 'LoadImageMask', '扩图最终合成直接使用单向羽化遮罩,避免二次模糊暴露灰底阴影' ); $outpaintNegative = ''; $outpaintPositive = ''; foreach ($outpaintInpaint as $nodeId => $node) { if (($node['class_type'] ?? '') !== 'CLIPTextEncode') { continue; } $text = (string) ($node['inputs']['text'] ?? ''); if (str_contains($text, 'mirrored content') || str_contains($text, 'tiled image')) { $outpaintNegative = $text; } elseif (str_contains($text, 'OUTPAINT_FULL_BLEED')) { $outpaintPositive = $text; } } $assert($outpaintNegative !== '', '扩图负面提示禁止镜像、平铺和复制'); $assert( !preg_match('/\b(?:plant|room|furniture|mockup|mirror|poster)\b/i', $outpaintPositive), '扩图正向 CLIP 提示不含会反向激活无关场景的禁用名词' ); $assert( AgentCatalog::requestsBackgroundRemoval('抠出主要人物,保留发丝细节,背景透明'), '抠图指令识别为背景移除任务' ); $assert( !AgentCatalog::requestsBackgroundRemoval('请介绍一下怎么做抠图'), '抠图教程问题保持文字任务' ); $assert( !AgentCatalog::requestsBackgroundRemoval('去除图片中的水印并用相邻背景自然补全,只修改水印区域'), '去水印默认提示不会误判为抠图' ); $assert( !AgentCatalog::requestsBackgroundRemoval('去掉右下角水印并自然补全背景'), '去水印建议文案不会误判为抠图' ); $assert( AgentCatalog::requestsBackgroundRemoval('去掉图片背景'), '明确去背景指令仍识别为抠图' ); $assert( AgentCatalog::requestsBackgroundRemoval('去除背景'), '最短去背景口语仍识别为抠图' ); $workbenchWatermarkPrompt = "去除图片中的水印并自然修复该区域纹理,只修改水印区域,保留人物、标题和其他内容。\n右下角水印去除"; $watermarkFlags = AgentCatalog::resolveImageEditFlags($workbenchWatermarkPrompt, 'watermark'); $assert(($watermarkFlags['is_watermark_removal'] ?? false) === true, '工作台去水印工具强制走去水印'); $assert(($watermarkFlags['is_background_removal'] ?? true) === false, '工作台去水印工具不会被内容误判为抠图'); $legacyWatermarkPrompt = "去除图片中的水印并用相邻背景自然补全,只修改水印区域,保留人物、标题和其他内容。\n右下角水印去除"; $legacyFlags = AgentCatalog::resolveImageEditFlags($legacyWatermarkPrompt, 'watermark'); $assert(($legacyFlags['is_watermark_removal'] ?? false) === true, '旧版去水印默认提示在 image_tool=watermark 时仍走去水印'); $assert(($legacyFlags['is_background_removal'] ?? true) === false, '旧版去水印默认提示在 image_tool=watermark 时不会抠图'); $chatWatermarkFlags = AgentCatalog::resolveImageEditFlags('去掉右下角水印并自然补全背景', ''); $assert(($chatWatermarkFlags['is_watermark_removal'] ?? false) === true, '对话去水印优先识别为去水印'); $assert(($chatWatermarkFlags['is_background_removal'] ?? true) === false, '对话去水印不会因补全背景误判为抠图'); $cutoutFlags = AgentCatalog::resolveImageEditFlags('任意无关文案', 'cutout'); $assert(($cutoutFlags['is_background_removal'] ?? false) === true, '工作台抠图工具强制走背景移除'); $assert(($cutoutFlags['is_watermark_removal'] ?? true) === false, '工作台抠图工具不会被误判为去水印'); $eraseFlags = AgentCatalog::resolveImageEditFlags('去掉涂抹区域的水印', 'erase'); $assert(($eraseFlags['is_background_removal'] ?? true) === false, '消除工具不会误判为抠图'); $assert(($eraseFlags['is_watermark_removal'] ?? false) === true, '消除工具提到水印时仍可走去水印强度'); $buildBackgroundRemovalWorkflow = $serviceReflection->getMethod('buildBackgroundRemovalWorkflow'); $buildBackgroundRemovalWorkflow->setAccessible(true); $cutoutWorkflow = $buildBackgroundRemovalWorkflow->invoke(null, 'chat_edits/cutout-source.png'); $cutoutTypes = array_column($cutoutWorkflow, 'class_type'); $assert(in_array('easy imageRemBg', $cutoutTypes, true), '抠图工作流使用本地 RMBG 节点'); $cutoutNode = null; foreach ($cutoutWorkflow as $node) { if (($node['class_type'] ?? '') === 'easy imageRemBg') { $cutoutNode = $node; break; } } $assert(($cutoutNode['inputs']['add_background'] ?? '') === 'none', '抠图工作流保留透明背景'); $assert(($cutoutNode['inputs']['image_output'] ?? '') === 'Save', '抠图工作流输出透明 PNG'); printf("RESULT %d checks; %d failed\n", $checks, count($failures)); exit($failures === [] ? 0 : 1);