770 lines
36 KiB
PHP
770 lines
36 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
$app = new think\App(dirname(__DIR__) . DIRECTORY_SEPARATOR);
|
|
$app->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', '', '<think>用户原始描述</think>final scene') === 'final scene',
|
|
'提示清理节点会移除包含用户原文的 think 区块'
|
|
);
|
|
$assert(
|
|
preg_replace('/' . str_replace('/', '\\/', $cleanupPattern) . '/is', '', '<think>未闭合的思考内容') === '',
|
|
'提示清理节点不会把未闭合思考链送进图像编码器'
|
|
);
|
|
|
|
$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);
|