mirror of
https://gitee.com/ulthon/ulthon_information.git
synced 2026-07-08 08:42:49 +08:00
feat(post): 新增手机图片排版与AI智能排版功能
- 新增手机图片排版功能,支持小红书/抖音尺寸输出 - 新增AI智能排版顾问,支持内容分析与优化推荐 - 新增AI供应商管理,支持多渠道配置与同步 - 新增文章输出管理页面,支持图片预览与批量下载 - 新增字体文件与排版样式配置
This commit is contained in:
87
app/common/tools/AiLayoutAdvisor.php
Normal file
87
app/common/tools/AiLayoutAdvisor.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\tools;
|
||||
|
||||
use app\common\tools\ai\AiChannelManager;
|
||||
use app\model\Post;
|
||||
|
||||
/**
|
||||
* AI 智能排版顾问.
|
||||
*
|
||||
* 分析文章内容并推荐排版配置,或优化内容以适配手机图片排版.
|
||||
*/
|
||||
class AiLayoutAdvisor
|
||||
{
|
||||
/**
|
||||
* 分析文章并推荐排版配置.
|
||||
*
|
||||
* @param Post $post 文章模型
|
||||
*
|
||||
* @return array ['success'=>bool, 'data'=>..., 'msg'=>...]
|
||||
*/
|
||||
public function analyzeAndRecommend(Post $post): array
|
||||
{
|
||||
try {
|
||||
$manager = new AiChannelManager();
|
||||
$provider = $manager->getDefaultProvider();
|
||||
|
||||
if (!$provider) {
|
||||
return ['success' => false, 'msg' => 'AI 功能暂不可用,请检查后台 AI 设置'];
|
||||
}
|
||||
|
||||
$systemPrompt = "你是一位专业的手机图文排版设计师。根据文章内容,推荐最适合的排版配置。"
|
||||
. "你必须返回JSON格式:{\"template\":\"minimal|magazine|mixed\",\"font\":\"source-han-sans|alibaba-puhuiti|lxgw-wenkai\","
|
||||
. "\"font_size\":14,\"reason\":\"推荐理由\"}"
|
||||
. "模板特点:minimal=白底黑字留白多适合长文,magazine=装饰线首字下沉适合正式文章,mixed=图文穿插适合图片多的文章。"
|
||||
. "字体特点:source-han-sans=思源黑体通用,alibaba-puhuiti=阿里巴巴普惠体现代感,lxgw-wenkai=霞鹜文楷文艺感。";
|
||||
|
||||
$contentPreview = mb_substr(strip_tags($post->content_html ?? ''), 0, 500);
|
||||
$imageCount = substr_count($post->content_html ?? '', '<img');
|
||||
|
||||
$userPrompt = "文章标题:{$post->title}\n"
|
||||
. '摘要:' . ($post->desc ?? '无') . "\n"
|
||||
. "正文前500字:{$contentPreview}\n"
|
||||
. "图片数量:{$imageCount}\n"
|
||||
. '分类:' . ($post->category_name ?? '未分类');
|
||||
|
||||
$result = $provider->chatJson($systemPrompt, $userPrompt);
|
||||
|
||||
return ['success' => true, 'data' => $result];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'msg' => 'AI 分析失败: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化内容以适配手机图片排版.
|
||||
*
|
||||
* @param Post $post 文章模型
|
||||
*
|
||||
* @return array ['success'=>bool, 'data'=>..., 'msg'=>...]
|
||||
*/
|
||||
public function optimizeContent(Post $post): array
|
||||
{
|
||||
try {
|
||||
$manager = new AiChannelManager();
|
||||
$provider = $manager->getDefaultProvider();
|
||||
|
||||
if (!$provider) {
|
||||
return ['success' => false, 'msg' => 'AI 功能暂不可用,请检查后台 AI 设置'];
|
||||
}
|
||||
|
||||
$systemPrompt = "你是一位专业的内容编辑。优化以下文章内容,使其更适合在手机图片上展示。"
|
||||
. "要求:1.标题更简短有力 2.段落精简(每段不超过100字) 3.提炼3-5个要点"
|
||||
. "返回JSON:{\"optimized_title\":\"标题\",\"optimized_paragraphs\":[\"段落1\",\"段落2\"],\"summary_points\":[\"要点1\",\"要点2\"]}";
|
||||
|
||||
$content = mb_substr(strip_tags($post->content_html ?? ''), 0, 1000);
|
||||
|
||||
$userPrompt = "文章标题:{$post->title}\n原文内容:{$content}";
|
||||
|
||||
$result = $provider->chatJson($systemPrompt, $userPrompt);
|
||||
|
||||
return ['success' => true, 'data' => $result];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'msg' => 'AI 内容优化失败: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
215
app/common/tools/PhoneImage.php
Normal file
215
app/common/tools/PhoneImage.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\tools;
|
||||
|
||||
use app\model\Post;
|
||||
use app\model\PostOutput;
|
||||
use app\model\PostOutputFile;
|
||||
use think\facade\App;
|
||||
|
||||
class PhoneImage implements PostOutputManagerInterface
|
||||
{
|
||||
/**
|
||||
* 返回配置字段定义
|
||||
*/
|
||||
public function getConfigFields(): array
|
||||
{
|
||||
return [
|
||||
'template' => ['type' => 'select', 'options' => ['minimal', 'magazine', 'mixed'], 'default' => 'minimal'],
|
||||
'size' => ['type' => 'select', 'options' => ['xiaohongshu', 'douyin'], 'default' => 'xiaohongshu'],
|
||||
'font' => ['type' => 'select', 'options' => ['source-han-sans', 'alibaba-puhuiti', 'lxgw-wenkai'], 'default' => 'source-han-sans'],
|
||||
'font_size' => ['type' => 'number', 'default' => 14, 'min' => 10, 'max' => 24],
|
||||
'watermark' => ['type' => 'text', 'default' => ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置合法性
|
||||
*/
|
||||
public function validateConfig(array $config): bool
|
||||
{
|
||||
$fields = $this->getConfigFields();
|
||||
|
||||
if (isset($config['template']) && !in_array($config['template'], $fields['template']['options'])) {
|
||||
return false;
|
||||
}
|
||||
if (isset($config['size']) && !in_array($config['size'], $fields['size']['options'])) {
|
||||
return false;
|
||||
}
|
||||
if (isset($config['font']) && !in_array($config['font'], $fields['font']['options'])) {
|
||||
return false;
|
||||
}
|
||||
if (isset($config['font_size'])) {
|
||||
$size = intval($config['font_size']);
|
||||
if ($size < $fields['font_size']['min'] || $size > $fields['font_size']['max']) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行输出处理 - 服务端接收base64数据,保存文件,创建记录
|
||||
* 注意:HTML渲染在前端完成(html2canvas),服务端只做文件保存和记录创建
|
||||
*/
|
||||
public function process(Post $post, array $config): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回预览HTML - 预览在前端完成,服务端返回空
|
||||
*/
|
||||
public function getPreview(Post $post, array $config): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存单页图片
|
||||
* @param int $outputId 输出记录ID
|
||||
* @param int $page 页码(从1开始)
|
||||
* @param string $imageData base64编码的图片数据
|
||||
* @return PostOutputFile|false
|
||||
*/
|
||||
public function savePageImage(int $outputId, int $page, string $imageData)
|
||||
{
|
||||
$imageData = str_replace('data:image/jpeg;base64,', '', $imageData);
|
||||
$imageData = str_replace('data:image/png;base64,', '', $imageData);
|
||||
$imageData = str_replace(' ', '+', $imageData);
|
||||
$decoded = base64_decode($imageData);
|
||||
|
||||
if ($decoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dateDir = date('Ymd');
|
||||
$relativeDir = '/upload/post_output/' . $dateDir;
|
||||
$fileName = $outputId . '_' . $page . '.jpg';
|
||||
$relativePath = $relativeDir . '/' . $fileName;
|
||||
$fullDir = App::getRootPath() . '/public' . $relativeDir;
|
||||
$fullPath = $fullDir . '/' . $fileName;
|
||||
|
||||
if (!is_dir($fullDir)) {
|
||||
mkdir($fullDir, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($fullPath, $decoded);
|
||||
|
||||
$imageInfo = getimagesize($fullPath);
|
||||
$width = $imageInfo[0] ?? 0;
|
||||
$height = $imageInfo[1] ?? 0;
|
||||
|
||||
$fileRecord = PostOutputFile::create([
|
||||
'output_id' => $outputId,
|
||||
'page' => $page,
|
||||
'file_path' => $relativePath,
|
||||
'file_url' => $relativePath,
|
||||
'file_size' => strlen($decoded),
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
]);
|
||||
|
||||
return $fileRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包某条输出记录的所有图片为ZIP
|
||||
* @param int $outputId
|
||||
* @return string ZIP文件路径
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createZip(int $outputId): string
|
||||
{
|
||||
$output = PostOutput::find($outputId);
|
||||
if (!$output) {
|
||||
throw new \Exception('输出记录不存在');
|
||||
}
|
||||
|
||||
$files = PostOutputFile::where('output_id', $outputId)
|
||||
->order('page', 'asc')
|
||||
->select();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
throw new \Exception('没有可下载的文件');
|
||||
}
|
||||
|
||||
$tempDir = App::getRuntimePath() . 'temp';
|
||||
if (!is_dir($tempDir)) {
|
||||
mkdir($tempDir, 0777, true);
|
||||
}
|
||||
|
||||
$zipFileName = 'post_output_' . $outputId . '.zip';
|
||||
$zipPath = $tempDir . '/' . $zipFileName;
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \Exception('无法创建ZIP文件');
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filePath = App::getRootPath() . '/public' . $file->file_path;
|
||||
if (file_exists($filePath)) {
|
||||
$pageName = str_pad((string) $file->page, 2, '0', STR_PAD_LEFT) . '.jpg';
|
||||
$zip->addFile($filePath, $pageName);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除输出记录及所有关联文件
|
||||
* @param int $outputId
|
||||
* @return bool
|
||||
*/
|
||||
public function deleteOutput(int $outputId): bool
|
||||
{
|
||||
$files = PostOutputFile::where('output_id', $outputId)->select();
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filePath = App::getRootPath() . '/public' . $file->file_path;
|
||||
if (file_exists($filePath)) {
|
||||
@unlink($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
PostOutputFile::where('output_id', $outputId)->delete();
|
||||
PostOutput::destroy($outputId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建输出记录
|
||||
* @param int $postId 文章ID
|
||||
* @param array $config 配置
|
||||
* @param int $adminId 管理员ID
|
||||
* @return PostOutput
|
||||
*/
|
||||
public function createOutput(int $postId, array $config, int $adminId): PostOutput
|
||||
{
|
||||
return PostOutput::create([
|
||||
'post_id' => $postId,
|
||||
'output_type' => 'phone_image',
|
||||
'config' => $config,
|
||||
'status' => PostOutput::STATUS_PROCESSING,
|
||||
'page_count' => 0,
|
||||
'admin_id' => $adminId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成输出记录 - 更新状态和页数
|
||||
*/
|
||||
public function completeOutput(int $outputId, int $pageCount): bool
|
||||
{
|
||||
return PostOutput::where('id', $outputId)->update([
|
||||
'status' => PostOutput::STATUS_COMPLETED,
|
||||
'page_count' => $pageCount,
|
||||
]) > 0;
|
||||
}
|
||||
}
|
||||
28
app/common/tools/PostOutputManagerInterface.php
Normal file
28
app/common/tools/PostOutputManagerInterface.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\tools;
|
||||
|
||||
use app\model\Post;
|
||||
|
||||
interface PostOutputManagerInterface
|
||||
{
|
||||
/**
|
||||
* 返回该输出类型需要的配置字段定义
|
||||
*/
|
||||
public function getConfigFields(): array;
|
||||
|
||||
/**
|
||||
* 验证配置合法性
|
||||
*/
|
||||
public function validateConfig(array $config): bool;
|
||||
|
||||
/**
|
||||
* 执行输出处理,返回文件信息数组
|
||||
*/
|
||||
public function process(Post $post, array $config): array;
|
||||
|
||||
/**
|
||||
* 返回预览HTML
|
||||
*/
|
||||
public function getPreview(Post $post, array $config): string;
|
||||
}
|
||||
186
app/common/tools/ai/AiChannelManager.php
Normal file
186
app/common/tools/ai/AiChannelManager.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\tools\ai;
|
||||
|
||||
use app\common\tools\ai\provider\OpenAiCompatibleAdapter;
|
||||
|
||||
/**
|
||||
* AI 渠道管理器.
|
||||
*
|
||||
* 管理多个AI供应商的配置、连接和调用.
|
||||
*/
|
||||
class AiChannelManager
|
||||
{
|
||||
/**
|
||||
* @var array 已注册的供应商适配器实例
|
||||
*/
|
||||
protected $providers = [];
|
||||
|
||||
/**
|
||||
* 获取默认供应商的适配器实例.
|
||||
*
|
||||
* @return AiProviderInterface|null
|
||||
*/
|
||||
public function getDefaultProvider(): ?AiProviderInterface
|
||||
{
|
||||
$defaultId = get_system_config('ai_default_provider', '');
|
||||
|
||||
if (empty($defaultId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getProvider($defaultId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定供应商的适配器实例.
|
||||
*
|
||||
* @param string $providerId 供应商标识
|
||||
*
|
||||
* @return AiProviderInterface|null
|
||||
*/
|
||||
public function getProvider(string $providerId): ?AiProviderInterface
|
||||
{
|
||||
if (isset($this->providers[$providerId])) {
|
||||
return $this->providers[$providerId];
|
||||
}
|
||||
|
||||
$apiKey = get_system_config("ai_provider_{$providerId}_key", '');
|
||||
if (empty($apiKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = new OpenAiCompatibleAdapter($providerId);
|
||||
$this->providers[$providerId] = $provider;
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已配置(有API Key)的供应商列表.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAvailableProviders(): array
|
||||
{
|
||||
$sync = new ModelsDevSync();
|
||||
$allProviders = $sync->getProviders();
|
||||
|
||||
$available = [];
|
||||
foreach ($allProviders as $id => $provider) {
|
||||
$apiKey = get_system_config("ai_provider_{$id}_key", '');
|
||||
if (!empty($apiKey)) {
|
||||
$provider['configured'] = true;
|
||||
$provider['base_url'] = get_system_config("ai_provider_{$id}_base_url", $provider['api_base_url'] ?? '');
|
||||
$available[$id] = $provider;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查不在models.dev中但已手动配置的供应商
|
||||
$allConfig = get_system_config();
|
||||
if (is_array($allConfig)) {
|
||||
foreach ($allConfig as $key => $value) {
|
||||
if (preg_match('/^ai_provider_([a-z0-9_]+)_key$/', $key, $matches)) {
|
||||
$customId = $matches[1];
|
||||
if (!empty($value) && !isset($available[$customId])) {
|
||||
$available[$customId] = [
|
||||
'id' => $customId,
|
||||
'name' => ucfirst(str_replace('_', ' ', $customId)),
|
||||
'configured' => true,
|
||||
'base_url' => get_system_config("ai_provider_{$customId}_base_url", ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $available;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存供应商配置.
|
||||
*
|
||||
* @param array $data 配置数据,格式:
|
||||
* [
|
||||
* 'provider_id' => 'zhipu',
|
||||
* 'key' => 'api_key_value',
|
||||
* 'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
|
||||
* ]
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function saveChannelConfig(array $data): bool
|
||||
{
|
||||
$providerId = $data['provider_id'] ?? '';
|
||||
if (empty($providerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$configs = [
|
||||
"ai_provider_{$providerId}_key" => $data['key'] ?? '',
|
||||
"ai_provider_{$providerId}_base_url" => $data['base_url'] ?? '',
|
||||
];
|
||||
|
||||
if (isset($data['is_default']) && $data['is_default']) {
|
||||
$configs['ai_default_provider'] = $providerId;
|
||||
}
|
||||
|
||||
if (isset($data['default_model'])) {
|
||||
$configs['ai_default_model'] = $data['default_model'];
|
||||
}
|
||||
|
||||
return $this->saveConfigs($configs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除供应商配置.
|
||||
*
|
||||
* @param string $providerId 供应商标识
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function deleteChannelConfig(string $providerId): bool
|
||||
{
|
||||
$configs = [
|
||||
"ai_provider_{$providerId}_key" => '',
|
||||
"ai_provider_{$providerId}_base_url" => '',
|
||||
];
|
||||
|
||||
// 如果删除的是默认供应商,清除默认设置
|
||||
$defaultProvider = get_system_config('ai_default_provider', '');
|
||||
if ($defaultProvider === $providerId) {
|
||||
$configs['ai_default_provider'] = '';
|
||||
}
|
||||
|
||||
return $this->saveConfigs($configs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存配置到SystemConfig.
|
||||
*
|
||||
* @param array $configs 键值对配置
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function saveConfigs(array $configs): bool
|
||||
{
|
||||
$list = \app\model\SystemConfig::column('value', 'name');
|
||||
|
||||
foreach ($configs as $key => $value) {
|
||||
if (isset($list[$key])) {
|
||||
\app\model\SystemConfig::where('name', $key)->update(['value' => $value]);
|
||||
} else {
|
||||
$model = new \app\model\SystemConfig();
|
||||
$model->name = $key;
|
||||
$model->value = $value;
|
||||
$model->save();
|
||||
}
|
||||
|
||||
$list[$key] = $value;
|
||||
}
|
||||
|
||||
\think\facade\Cache::set('system_config', $list);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
52
app/common/tools/ai/AiProviderInterface.php
Normal file
52
app/common/tools/ai/AiProviderInterface.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\tools\ai;
|
||||
|
||||
/**
|
||||
* AI 供应商接口.
|
||||
*/
|
||||
interface AiProviderInterface
|
||||
{
|
||||
/**
|
||||
* 发送聊天请求.
|
||||
*
|
||||
* @param string $systemPrompt 系统提示词
|
||||
* @param string $userPrompt 用户消息
|
||||
* @param array $options 额外选项(model, temperature, max_tokens等)
|
||||
*
|
||||
* @return string AI回复内容
|
||||
*/
|
||||
public function chat(string $systemPrompt, string $userPrompt, array $options = []): string;
|
||||
|
||||
/**
|
||||
* 发送聊天请求并返回JSON解析后的数组.
|
||||
*
|
||||
* @param string $systemPrompt 系统提示词
|
||||
* @param string $userPrompt 用户消息
|
||||
* @param array $options 额外选项
|
||||
*
|
||||
* @return array 解析后的JSON数组
|
||||
*/
|
||||
public function chatJson(string $systemPrompt, string $userPrompt, array $options = []): array;
|
||||
|
||||
/**
|
||||
* 获取供应商名称.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getProviderName(): string;
|
||||
|
||||
/**
|
||||
* 获取供应商支持的模型列表.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getModels(): array;
|
||||
|
||||
/**
|
||||
* 测试连接是否可用.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function testConnection(): bool;
|
||||
}
|
||||
237
app/common/tools/ai/ModelsDevSync.php
Normal file
237
app/common/tools/ai/ModelsDevSync.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\tools\ai;
|
||||
|
||||
/**
|
||||
* models.dev 数据同步与缓存.
|
||||
*
|
||||
* 从 https://models.dev/api.json 获取AI模型供应商和模型目录数据,
|
||||
* 缓存到 runtime/ai/models_dev_cache.json, 有效期24小时.
|
||||
*/
|
||||
class ModelsDevSync
|
||||
{
|
||||
/**
|
||||
* @var string models.dev API地址
|
||||
*/
|
||||
protected $apiUrl = 'https://models.dev/api.json';
|
||||
|
||||
/**
|
||||
* @var string 缓存文件路径
|
||||
*/
|
||||
protected $cachePath;
|
||||
|
||||
/**
|
||||
* @var int 缓存有效期(秒), 默认24小时
|
||||
*/
|
||||
protected $cacheTtl = 86400;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->cachePath = app()->getRuntimePath() . 'ai' . DIRECTORY_SEPARATOR . 'models_dev_cache.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步供应商数据到本地缓存.
|
||||
*
|
||||
* @return array 同步结果统计
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function syncProviders(): array
|
||||
{
|
||||
$data = $this->fetchApi();
|
||||
|
||||
if ($data === false) {
|
||||
throw new \Exception('无法连接 models.dev API');
|
||||
}
|
||||
|
||||
$parsed = $this->parseApiData($data);
|
||||
|
||||
$this->writeCache($parsed);
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有供应商列表.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getProviders(): array
|
||||
{
|
||||
$cache = $this->readCache();
|
||||
if ($cache === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $cache['providers'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定供应商的模型列表.
|
||||
*
|
||||
* @param string $providerId 供应商标识
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getModelsByProvider(string $providerId): array
|
||||
{
|
||||
$cache = $this->readCache();
|
||||
if ($cache === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$models = $cache['models'][$providerId] ?? [];
|
||||
|
||||
return $models;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态信息.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getCacheStatus(): array
|
||||
{
|
||||
$exists = file_exists($this->cachePath);
|
||||
$mtime = $exists ? filemtime($this->cachePath) : 0;
|
||||
$expired = ($mtime + $this->cacheTtl) < time();
|
||||
|
||||
return [
|
||||
'exists' => $exists,
|
||||
'last_sync' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : '从未同步',
|
||||
'expired' => $expired,
|
||||
'provider_count' => count($this->getProviders()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从API获取原始数据.
|
||||
*
|
||||
* @return string|false
|
||||
*/
|
||||
protected function fetchApi()
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->apiUrl);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Accept: application/json',
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || $response === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析API数据为统一格式.
|
||||
*
|
||||
* @param string $raw JSON原始数据
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function parseApiData(string $raw): array
|
||||
{
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return ['providers' => [], 'models' => []];
|
||||
}
|
||||
|
||||
$providers = [];
|
||||
$models = [];
|
||||
|
||||
foreach ($data as $providerId => $providerData) {
|
||||
if (!is_array($providerData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$provider = [
|
||||
'id' => $providerId,
|
||||
'name' => $providerData['name'] ?? ucfirst($providerId),
|
||||
'url' => $providerData['url'] ?? '',
|
||||
'docs_url' => $providerData['docs_url'] ?? '',
|
||||
];
|
||||
|
||||
// 提取API基础地址(如果有)
|
||||
if (isset($providerData['api_base_url'])) {
|
||||
$provider['api_base_url'] = $providerData['api_base_url'];
|
||||
}
|
||||
|
||||
$providers[$providerId] = $provider;
|
||||
|
||||
// 提取模型列表
|
||||
$providerModels = [];
|
||||
if (isset($providerData['models']) && is_array($providerData['models'])) {
|
||||
foreach ($providerData['models'] as $modelId => $modelData) {
|
||||
$model = [
|
||||
'id' => $modelId,
|
||||
'name' => $modelData['name'] ?? $modelId,
|
||||
'description' => $modelData['description'] ?? '',
|
||||
];
|
||||
|
||||
if (isset($modelData['context_length'])) {
|
||||
$model['context_length'] = (int) $modelData['context_length'];
|
||||
}
|
||||
if (isset($modelData['pricing'])) {
|
||||
$model['pricing'] = $modelData['pricing'];
|
||||
}
|
||||
|
||||
$providerModels[] = $model;
|
||||
}
|
||||
}
|
||||
|
||||
$models[$providerId] = $providerModels;
|
||||
}
|
||||
|
||||
return [
|
||||
'providers' => $providers,
|
||||
'models' => $models,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入缓存文件.
|
||||
*
|
||||
* @param array $data 缓存数据
|
||||
*/
|
||||
protected function writeCache(array $data): void
|
||||
{
|
||||
$dir = dirname($this->cachePath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($this->cachePath, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取缓存文件(支持过期降级).
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected function readCache(): ?array
|
||||
{
|
||||
if (!file_exists($this->cachePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($this->cachePath);
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
228
app/common/tools/ai/provider/OpenAiCompatibleAdapter.php
Normal file
228
app/common/tools/ai/provider/OpenAiCompatibleAdapter.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\tools\ai\provider;
|
||||
|
||||
use app\common\tools\ai\AiProviderInterface;
|
||||
|
||||
/**
|
||||
* OpenAI 兼容适配器.
|
||||
*
|
||||
* 适用于所有兼容 OpenAI API 格式的供应商:
|
||||
* OpenAI / Zhipu / DeepSeek / Moonshot / Qwen / etc.
|
||||
*/
|
||||
class OpenAiCompatibleAdapter implements AiProviderInterface
|
||||
{
|
||||
/**
|
||||
* @var string 供应商标识(如 zhipu, deepseek)
|
||||
*/
|
||||
protected $providerId;
|
||||
|
||||
/**
|
||||
* @var string 供应商显示名称
|
||||
*/
|
||||
protected $providerName;
|
||||
|
||||
/**
|
||||
* @param string $providerId 供应商标识
|
||||
* @param string $providerName 显示名称(可选)
|
||||
*/
|
||||
public function __construct(string $providerId, string $providerName = '')
|
||||
{
|
||||
$this->providerId = $providerId;
|
||||
$this->providerName = $providerName ?: ucfirst($providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function chat(string $systemPrompt, string $userPrompt, array $options = []): string
|
||||
{
|
||||
$apiKey = get_system_config("ai_provider_{$this->providerId}_key", '');
|
||||
$baseUrl = get_system_config("ai_provider_{$this->providerId}_base_url", 'https://api.openai.com/v1');
|
||||
$model = $options['model'] ?? get_system_config('ai_default_model', 'gpt-3.5-turbo');
|
||||
|
||||
$url = rtrim($baseUrl, '/') . '/chat/completions';
|
||||
$data = [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
],
|
||||
];
|
||||
|
||||
if (isset($options['temperature'])) {
|
||||
$data['temperature'] = (float) $options['temperature'];
|
||||
}
|
||||
if (isset($options['max_tokens'])) {
|
||||
$data['max_tokens'] = (int) $options['max_tokens'];
|
||||
}
|
||||
|
||||
$response = $this->httpPost($url, $data, [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
]);
|
||||
|
||||
if ($response === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (!$result || isset($result['error'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $result['choices'][0]['message']['content'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function chatJson(string $systemPrompt, string $userPrompt, array $options = []): array
|
||||
{
|
||||
$jsonInstruction = "\n\n请严格按照JSON格式返回结果,不要包含任何其他文字说明。";
|
||||
$content = $this->chat($systemPrompt . $jsonInstruction, $userPrompt, $options);
|
||||
|
||||
if (empty($content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 尝试提取JSON内容(可能包裹在markdown代码块中)
|
||||
if (preg_match('/```(?:json)?\s*([\s\S]*?)```/', $content, $matches)) {
|
||||
$content = $matches[1];
|
||||
}
|
||||
|
||||
$decoded = json_decode(trim($content), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getProviderName(): string
|
||||
{
|
||||
return $this->providerName;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getModels(): array
|
||||
{
|
||||
$apiKey = get_system_config("ai_provider_{$this->providerId}_key", '');
|
||||
$baseUrl = get_system_config("ai_provider_{$this->providerId}_base_url", 'https://api.openai.com/v1');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = rtrim($baseUrl, '/') . '/models';
|
||||
$response = $this->httpGet($url, [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
]);
|
||||
|
||||
if ($response === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (!$result || !isset($result['data'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$models = [];
|
||||
foreach ($result['data'] as $model) {
|
||||
$models[] = [
|
||||
'id' => $model['id'] ?? '',
|
||||
'name' => $model['id'] ?? '',
|
||||
'owned_by' => $model['owned_by'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $models;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function testConnection(): bool
|
||||
{
|
||||
$apiKey = get_system_config("ai_provider_{$this->providerId}_key", '');
|
||||
if (empty($apiKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->chat(
|
||||
'You are a test assistant.',
|
||||
'Reply with exactly: OK',
|
||||
['max_tokens' => 5]
|
||||
);
|
||||
|
||||
return !empty($result);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP POST 请求.
|
||||
*
|
||||
* @param string $url 请求地址
|
||||
* @param array $data 请求数据
|
||||
* @param array $headers 请求头
|
||||
*
|
||||
* @return string|false
|
||||
*/
|
||||
protected function httpPost(string $url, array $data, array $headers = [])
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP GET 请求.
|
||||
*
|
||||
* @param string $url 请求地址
|
||||
* @param array $headers 请求头
|
||||
*
|
||||
* @return string|false
|
||||
*/
|
||||
protected function httpGet(string $url, array $headers = [])
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user