|
|
@@ -1,6 +1,7 @@
|
|
|
<?php
|
|
|
namespace app\service;
|
|
|
use think\Db;
|
|
|
+use think\Log;
|
|
|
use think\Queue;
|
|
|
class AIGatewayService{
|
|
|
|
|
|
@@ -50,6 +51,9 @@ class AIGatewayService{
|
|
|
case $status_val === '文生图' && $model === 'gemini-3-pro-preview':
|
|
|
$data = $this->buildText2ImageGemini3Pro($prompt, $size);
|
|
|
break;
|
|
|
+ case $status_val === '文生图' && $model === 'gemini-3-pro-image-preview':
|
|
|
+ $data = $this->buildText2ImageGemini3ProImage($prompt, $size);
|
|
|
+ break;
|
|
|
|
|
|
// 图生文
|
|
|
case $status_val === '图生文' && $model === 'gemini-3-pro-preview':
|
|
|
@@ -68,8 +72,11 @@ class AIGatewayService{
|
|
|
throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})");
|
|
|
}
|
|
|
|
|
|
- // 3. 统一调用 API(图生图耗时通常更长,适当放宽超时时间)
|
|
|
- $timeout = ($status_val === '图生图') ? 180 : 60;
|
|
|
+ // 3. 统一调用 API(图生图/文生图出图耗时较长,适当放宽超时时间)
|
|
|
+ $timeout = 60;
|
|
|
+ if ($status_val === '图生图' || ($status_val === '文生图' && $model === 'gemini-3-pro-image-preview')) {
|
|
|
+ $timeout = 180;
|
|
|
+ }
|
|
|
return $this->callApi($data, $model, $timeout);
|
|
|
}
|
|
|
|
|
|
@@ -144,6 +151,50 @@ class AIGatewayService{
|
|
|
];
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 文生图 - gemini-3-pro-image-preview 模型(纯文本出图)
|
|
|
+ */
|
|
|
+ private function buildText2ImageGemini3ProImage(string $prompt, string $size): array
|
|
|
+ {
|
|
|
+ $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16'];
|
|
|
+ if ($size === '' || !in_array($size, $supportedAspectRatios, true)) {
|
|
|
+ if (!empty($size) && strpos($size, 'x') !== false) {
|
|
|
+ $parts = explode('x', trim($size), 2);
|
|
|
+ if (count($parts) === 2) {
|
|
|
+ $w = (int)$parts[0];
|
|
|
+ $h = (int)$parts[1];
|
|
|
+ if ($w > 0 && $h > 0) {
|
|
|
+ $ratio = $w / $h;
|
|
|
+ $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]];
|
|
|
+ $minDiff = PHP_FLOAT_MAX;
|
|
|
+ foreach ($standard as $r) {
|
|
|
+ $diff = abs($ratio - $r[1]);
|
|
|
+ if ($diff < $minDiff) {
|
|
|
+ $minDiff = $diff;
|
|
|
+ $size = $r[0];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ $size = '1:1';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'contents' => [
|
|
|
+ [
|
|
|
+ 'role' => 'user',
|
|
|
+ 'parts' => [['text' => $prompt]]
|
|
|
+ ]
|
|
|
+ ],
|
|
|
+ 'generationConfig' => [
|
|
|
+ 'responseModalities' => ['IMAGE'],
|
|
|
+ 'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K']
|
|
|
+ ]
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 图生文 - gemini-3-pro-preview 模型
|
|
|
*/
|
|
|
@@ -187,7 +238,7 @@ class AIGatewayService{
|
|
|
$h = (int)$parts[1];
|
|
|
if ($w > 0 && $h > 0) {
|
|
|
$ratio = $w / $h;
|
|
|
- $standard = [['1:1', 1], ['4:3', 4/3], ['3:4', 3/4], ['16:9', 16/9], ['9:16', 9/16]];
|
|
|
+ $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]];
|
|
|
$minDiff = PHP_FLOAT_MAX;
|
|
|
foreach ($standard as $r) {
|
|
|
$diff = abs($ratio - $r[1]);
|
|
|
@@ -200,6 +251,12 @@ class AIGatewayService{
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 网关要求固定 2 张 inline_data;无参考图时用原图占位(与线上一致)
|
|
|
+ if ($templateBase64 === '' && $productBase64 !== '') {
|
|
|
+ $templateBase64 = $productBase64;
|
|
|
+ $templateMimeType = $productMimeType;
|
|
|
+ }
|
|
|
+
|
|
|
return [
|
|
|
'contents' => [
|
|
|
[
|
|
|
@@ -207,7 +264,7 @@ class AIGatewayService{
|
|
|
'parts' => [
|
|
|
['text' => $prompt],
|
|
|
['inline_data' => ['mime_type' => $productMimeType, 'data' => $productBase64]],
|
|
|
- ['inline_data' => ['mime_type' => $templateMimeType, 'data' => $templateBase64]]
|
|
|
+ ['inline_data' => ['mime_type' => $templateMimeType, 'data' => $templateBase64]],
|
|
|
]
|
|
|
]
|
|
|
],
|
|
|
@@ -229,22 +286,26 @@ class AIGatewayService{
|
|
|
string $templateBase64,
|
|
|
string $templateMimeType
|
|
|
): array {
|
|
|
+ $content = [
|
|
|
+ ['type' => 'text', 'text' => $prompt],
|
|
|
+ [
|
|
|
+ 'type' => 'image_url',
|
|
|
+ 'image_url' => ['url' => 'data:' . $productMimeType . ';base64,' . $productBase64]
|
|
|
+ ],
|
|
|
+ ];
|
|
|
+ if (!empty($templateMimeType) && !empty($templateBase64)) {
|
|
|
+ $content[] = [
|
|
|
+ 'type' => 'image_url',
|
|
|
+ 'image_url' => ['url' => 'data:' . $templateMimeType . ';base64,' . $templateBase64]
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
return [
|
|
|
'model' => 'gemini-3.1-flash-image-preview',
|
|
|
'messages' => [
|
|
|
[
|
|
|
'role' => 'user',
|
|
|
- 'content' => [
|
|
|
- ['type' => 'text', 'text' => $prompt],
|
|
|
- [
|
|
|
- 'type' => 'image_url',
|
|
|
- 'image_url' => ['url' => 'data:' . $productMimeType . ';base64,' . $productBase64]
|
|
|
- ],
|
|
|
- [
|
|
|
- 'type' => 'image_url',
|
|
|
- 'image_url' => ['url' => 'data:' . $templateMimeType . ';base64,' . $templateBase64]
|
|
|
- ]
|
|
|
- ]
|
|
|
+ 'content' => $content
|
|
|
]
|
|
|
],
|
|
|
'response_modalities' => ['image'],
|
|
|
@@ -800,4 +861,271 @@ class AIGatewayService{
|
|
|
'mimeType' => $mimeType
|
|
|
];
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 AI 响应中提取图片 base64(兼容 Gemini / OpenAI 多种返回格式)
|
|
|
+ */
|
|
|
+ public function extractImageBase64FromResponse(array $res): ?string
|
|
|
+ {
|
|
|
+ // 兼容网关包装:{ "data": { "candidates": [...] } }
|
|
|
+ if (empty($res['candidates']) && !empty($res['data']) && is_array($res['data'])) {
|
|
|
+ if (!empty($res['data']['candidates'])) {
|
|
|
+ $res = array_merge($res, $res['data']);
|
|
|
+ } elseif (!empty($res['data']['choices'])) {
|
|
|
+ $res = array_merge($res, $res['data']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!empty($res['data'][0]['b64_json'])) {
|
|
|
+ return preg_replace('/\s+/', '', (string)$res['data'][0]['b64_json']);
|
|
|
+ }
|
|
|
+ if (!empty($res['data'][0]['url'])) {
|
|
|
+ $content = @file_get_contents((string)$res['data'][0]['url']);
|
|
|
+ if ($content !== false && $content !== '') {
|
|
|
+ return base64_encode($content);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!empty($res['choices'][0]['message']['content'])) {
|
|
|
+ $content = $res['choices'][0]['message']['content'];
|
|
|
+ if (is_string($content)) {
|
|
|
+ $parsed = $this->parseBase64FromText($content);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ } elseif (is_array($content)) {
|
|
|
+ foreach ($content as $item) {
|
|
|
+ if (($item['type'] ?? '') === 'image_url' && !empty($item['image_url']['url'])) {
|
|
|
+ $parsed = $this->parseBase64FromText((string)$item['image_url']['url']);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!empty($item['b64_json'])) {
|
|
|
+ return preg_replace('/\s+/', '', (string)$item['b64_json']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Gemini 出图(与 TextToImageJob 线上一致)
|
|
|
+ if (!empty($res['candidates'][0]['content']['parts']) && is_array($res['candidates'][0]['content']['parts'])) {
|
|
|
+ foreach ($res['candidates'][0]['content']['parts'] as $part) {
|
|
|
+ foreach (['inlineData', 'inline_data'] as $key) {
|
|
|
+ if (!empty($part[$key]['data'])) {
|
|
|
+ $raw = preg_replace('/\s+/', '', (string)$part[$key]['data']);
|
|
|
+ if (preg_match('/^data:image\//i', $raw)) {
|
|
|
+ $parsed = $this->parseBase64FromText($raw);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ } elseif (strlen($raw) > 100 && base64_decode($raw, true) !== false) {
|
|
|
+ return $raw;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!empty($part['text']) && preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)$/is', (string)$part['text'], $m)) {
|
|
|
+ return preg_replace('/\s+/', '', $m[2]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!empty($res['candidates']) && is_array($res['candidates'])) {
|
|
|
+ foreach ($res['candidates'] as $candidate) {
|
|
|
+ $parts = $candidate['content']['parts'] ?? [];
|
|
|
+ foreach ($parts as $part) {
|
|
|
+ foreach (['inlineData', 'inline_data'] as $key) {
|
|
|
+ if (!empty($part[$key]['data'])) {
|
|
|
+ $parsed = $this->parseBase64FromText((string)$part[$key]['data']);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ foreach (['fileData', 'file_data'] as $key) {
|
|
|
+ $uri = $part[$key]['fileUri'] ?? ($part[$key]['file_uri'] ?? '');
|
|
|
+ if ($uri !== '') {
|
|
|
+ $parsed = $this->fetchImageBase64FromUri((string)$uri);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!empty($part['text'])) {
|
|
|
+ $parsed = $this->parseBase64FromText((string)$part['text']);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->findImageBase64Deep($res);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 深度递归扫描响应中的图片数据
|
|
|
+ */
|
|
|
+ private function findImageBase64Deep($node, int $depth = 0): ?string
|
|
|
+ {
|
|
|
+ if ($depth > 15) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (is_string($node)) {
|
|
|
+ return $this->parseBase64FromText($node);
|
|
|
+ }
|
|
|
+ if (!is_array($node)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (['inlineData', 'inline_data'] as $key) {
|
|
|
+ if (!empty($node[$key]['data'])) {
|
|
|
+ $parsed = $this->parseBase64FromText((string)$node[$key]['data']);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!empty($node['b64_json'])) {
|
|
|
+ return preg_replace('/\s+/', '', (string)$node['b64_json']);
|
|
|
+ }
|
|
|
+ if (!empty($node['url']) && is_string($node['url'])) {
|
|
|
+ $parsed = $this->fetchImageBase64FromUri($node['url']);
|
|
|
+ if ($parsed) {
|
|
|
+ return $parsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($node as $value) {
|
|
|
+ if (is_array($value) || is_string($value)) {
|
|
|
+ $found = $this->findImageBase64Deep($value, $depth + 1);
|
|
|
+ if ($found) {
|
|
|
+ return $found;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 URL / data URI 拉取图片并转 base64
|
|
|
+ */
|
|
|
+ private function fetchImageBase64FromUri(string $uri): ?string
|
|
|
+ {
|
|
|
+ $uri = trim($uri);
|
|
|
+ if ($uri === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (strpos($uri, 'data:') === 0) {
|
|
|
+ return $this->parseBase64FromText($uri);
|
|
|
+ }
|
|
|
+ if (!preg_match('/^https?:\/\//i', $uri)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $content = @file_get_contents($uri);
|
|
|
+ if ($content === false || $content === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return base64_encode($content);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 图片提取失败时的可读错误信息
|
|
|
+ */
|
|
|
+ public function describeImageExtractFailure(array $res): string
|
|
|
+ {
|
|
|
+ if (!empty($res['error']['message'])) {
|
|
|
+ return (string)$res['error']['message'];
|
|
|
+ }
|
|
|
+
|
|
|
+ $text = '';
|
|
|
+ if (!empty($res['candidates'][0]['content']['parts'])) {
|
|
|
+ foreach ($res['candidates'][0]['content']['parts'] as $part) {
|
|
|
+ if (!empty($part['text']) && $text === '') {
|
|
|
+ $text = (string)$part['text'];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if ($text === '' && !empty($res['choices'][0]['message']['content'])) {
|
|
|
+ $msgContent = $res['choices'][0]['message']['content'];
|
|
|
+ if (is_string($msgContent)) {
|
|
|
+ $text = $msgContent;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $reason = $res['candidates'][0]['finishReason']
|
|
|
+ ?? ($res['choices'][0]['finish_reason'] ?? '');
|
|
|
+ if ($text !== '') {
|
|
|
+ $prefix = $reason !== '' ? "模型未返回图片({$reason})" : '未获取到图片数据';
|
|
|
+ return $prefix . ': ' . mb_substr($text, 0, 150);
|
|
|
+ }
|
|
|
+ if ($reason !== '' && strtoupper((string)$reason) !== 'STOP') {
|
|
|
+ return '模型未返回图片: ' . $reason;
|
|
|
+ }
|
|
|
+
|
|
|
+ return '未获取到图片数据,请检查模型是否支持出图';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 图片提取失败时写入调试日志(截断 base64,避免日志过大)
|
|
|
+ */
|
|
|
+ public function logImageResponseDebug(array $res, string $taskId = ''): void
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ $sanitized = $this->sanitizeResponseForLog($res);
|
|
|
+ Log::write(
|
|
|
+ '[AI image extract failed] task=' . $taskId . ' response=' . mb_substr(json_encode($sanitized, JSON_UNESCAPED_UNICODE), 0, 4000),
|
|
|
+ 'error'
|
|
|
+ );
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ // 日志失败不阻断
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param mixed $node
|
|
|
+ * @return mixed
|
|
|
+ */
|
|
|
+ private function sanitizeResponseForLog($node, int $depth = 0)
|
|
|
+ {
|
|
|
+ if ($depth > 10) {
|
|
|
+ return '...';
|
|
|
+ }
|
|
|
+ if (is_string($node)) {
|
|
|
+ return strlen($node) > 100 ? (substr($node, 0, 50) . '...(len=' . strlen($node) . ')') : $node;
|
|
|
+ }
|
|
|
+ if (!is_array($node)) {
|
|
|
+ return $node;
|
|
|
+ }
|
|
|
+ $out = [];
|
|
|
+ foreach ($node as $key => $value) {
|
|
|
+ if (in_array($key, ['data', 'b64_json'], true) && is_string($value) && strlen($value) > 100) {
|
|
|
+ $out[$key] = '...(len=' . strlen($value) . ')';
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $out[$key] = $this->sanitizeResponseForLog($value, $depth + 1);
|
|
|
+ }
|
|
|
+ return $out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析 data:image/...;base64 或裸 base64 字符串
|
|
|
+ */
|
|
|
+ private function parseBase64FromText(string $content): ?string
|
|
|
+ {
|
|
|
+ $content = trim($content);
|
|
|
+ if ($content === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (preg_match('/data:image\/(?:png|jpg|jpeg|webp);base64,(.+)$/is', $content, $m)) {
|
|
|
+ return preg_replace('/\s+/', '', $m[1]);
|
|
|
+ }
|
|
|
+ $clean = preg_replace('/\s+/', '', $content);
|
|
|
+ if (strlen($clean) > 100 && base64_decode($clean, true) !== false) {
|
|
|
+ return $clean;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
}
|