Files
laralog/app/Blog/Services/CoverGenerator.php
T
gouki 6aae919bf7
Build and Deploy / verify (push) Successful in 10s
Build and Deploy / deploy (push) Successful in 17s
fix(covers): retry without prompt_optimizer on silent empty-image failure
2026-10-15 00:11:42 +00:00

136 lines
4.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Blog\Services;
use App\Models\Post;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* AI 封面生成:复用「AI 设置」的 LLM 配置调 OpenAI 兼容 /image_generation 接口
* (如 MiniMax image-01),图片存入 media cover 集合(singleFile,重复生成自动替换)。
*/
class CoverGenerator
{
public function __construct(private LlmClient $llm) {}
public function isConfigured(): bool
{
return $this->llm()->isConfigured();
}
public function model(?string $override = null): string
{
// 优先级:命令行参数 > llm_image_model 设置 > 图片 profile 的模型 > image-01
$profileModel = blog_setting('llm_profile_image') ? $this->llm()->model() : null;
return $override
?: (string) (blog_setting('llm_image_model') ?: $profileModel ?: 'image-01');
}
/**
* 封面生成使用的 LLM 配置(可在 AI 设置里指定独立的服务商)。
*/
private function llm(): LlmClient
{
return $this->llm->forProfile((string) blog_setting('llm_profile_image', ''));
}
public function generateFor(Post $post, ?string $model = null, ?string $style = null): void
{
if (! $this->llm()->isConfigured()) {
throw new \RuntimeException('LLM 未配置:请先在后台「AI 设置」配置 Base URL / API Key');
}
// 站点无常驻 queue worker:同步产出 card 转换,避免缩略图永远卡在队列里。
config(['media-library.queue_conversions_by_default' => false]);
$response = $this->requestImage($this->buildPrompt($post, $style), $model);
// 标题触发内容审核(input new_sensitive):退化为不含标题的通用提示词重试一次。
if (str_contains((string) $response->body(), 'sensitive')
&& ! is_string($response->json('data.image_urls.0'))) {
$response = $this->requestImage($this->neutralPrompt($style), $model);
}
// 少数提示词在 prompt_optimizer 下静默失败(success 但无图):关掉优化器重试一次。
if ($response->successful() && ! is_string($response->json('data.image_urls.0'))) {
$response = $this->requestImage($this->buildPrompt($post, $style), $model, false);
}
if ($response->failed()) {
throw new \RuntimeException('HTTP '.$response->status().':'.Str::limit($response->body(), 200));
}
$url = $response->json('data.image_urls.0');
if (! is_string($url) || $url === '') {
$reason = $response->json('base_resp.status_msg') ?: $response->body();
throw new \RuntimeException('接口未返回图片:'.Str::limit((string) $reason, 200));
}
$image = Http::timeout(60)->get($url)->throw();
$ext = match (strtolower(explode(';', (string) $image->header('Content-Type'))[0])) {
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg',
};
$tmp = tempnam(sys_get_temp_dir(), 'cover_');
file_put_contents($tmp, $image->body());
try {
$post->addMedia($tmp)
->usingFileName('cover-'.$post->id.'.'.$ext)
->toMediaCollection('cover');
} finally {
@unlink($tmp);
}
}
public function buildPrompt(Post $post, ?string $style = null): string
{
$style = trim((string) ($style
?: blog_setting('cover_prompt_style', '扁平插画风格,色调柔和,构图简洁')));
$excerpt = Str::limit(trim(strip_tags((string) $post->excerpt)), 60, '');
$parts = ["为博客文章《{$post->title}》生成一张封面配图"];
if ($excerpt !== '') {
$parts[] = "内容大意:{$excerpt}";
}
$parts[] = $style;
$parts[] = '画面中不要出现任何文字';
return implode('。', $parts);
}
/**
* 不含标题/摘要的通用封面提示词(标题触发审核时的兜底)。
*/
public function neutralPrompt(?string $style = null): string
{
$style = trim((string) ($style
?: blog_setting('cover_prompt_style', '扁平插画风格,色调柔和,构图简洁')));
return "博客文章封面配图。{$style}。画面中不要出现任何文字";
}
private function requestImage(string $prompt, ?string $model = null, bool $optimizer = true): \Illuminate\Http\Client\Response
{
$llm = $this->llm();
return Http::timeout(120)
->withToken($llm->apiKey())
->post($llm->baseUrl().'/image_generation', [
'model' => $this->model($model),
'prompt' => $prompt,
'aspect_ratio' => '16:9',
'response_format' => 'url',
'n' => 1,
'prompt_optimizer' => $optimizer,
]);
}
}