Files
laralog/app/Blog/Services/CoverGenerator.php
T
gouki ddbf259e93
Build and Deploy / verify (push) Successful in 10s
Build and Deploy / deploy (push) Successful in 18s
fix(covers): fallback to neutral prompt when title trips content moderation
input new_sensitive 的标题(自杀/逝世等)改用不含标题的通用提示词重试一次
2026-10-14 23:52:00 +00:00

117 lines
4.1 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
{
return $override ?: (string) blog_setting('llm_image_model', 'image-01');
}
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);
}
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): \Illuminate\Http\Client\Response
{
return Http::timeout(120)
->withToken($this->llm->apiKey())
->post($this->llm->baseUrl().'/image_generation', [
'model' => $this->model($model),
'prompt' => $prompt,
'aspect_ratio' => '16:9',
'response_format' => 'url',
'n' => 1,
'prompt_optimizer' => true,
]);
}
}