Files
laralog/app/Blog/Services/CoverGenerator.php
T
gouki 2e052498a5
Build and Deploy / verify (push) Successful in 10s
Build and Deploy / deploy (push) Successful in 20s
feat: AI 任务同步执行、评论垃圾管理、会员导航分组、多 AI 配置
- 评论 AI 审核/文章润色改为同步执行(站点无 queue worker,任务曾永久卡队列,
  导致垃圾评论滞留待审核、润色毫无效果);审核在响应结束后经 terminating 触发
- 评论表:修复 approve 闭包里误嵌入 recordUrl 的坏链;新增「非垃圾」恢复发布、
  批量「标为垃圾」、工具栏「清空垃圾评论」;各操作补充通知反馈
- 会员套餐/订阅/订单移入新导航组「会员」
- LlmClient 支持多服务商 profile;AI 设置页可添加多个 AI 并为审核/润色/封面
  分别指定服务商(不指定用默认配置);封面生成同步支持 profile
- AI 设置页:润色改同步并即时展示结果,按钮加 loading;删除无 workerman 的
  WebSocket 进度区块
2026-10-15 00:06:55 +00:00

131 lines
4.6 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);
}
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
{
$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' => true,
]);
}
}