136 lines
4.9 KiB
PHP
136 lines
4.9 KiB
PHP
<?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,
|
||
]);
|
||
}
|
||
}
|