feat(post): 新增手机图片排版与AI智能排版功能

- 新增手机图片排版功能,支持小红书/抖音尺寸输出
- 新增AI智能排版顾问,支持内容分析与优化推荐
- 新增AI供应商管理,支持多渠道配置与同步
- 新增文章输出管理页面,支持图片预览与批量下载
- 新增字体文件与排版样式配置
This commit is contained in:
augushong
2026-05-01 12:23:17 +08:00
parent b4558b55fb
commit 83a2bd48a2
25 changed files with 4440 additions and 0 deletions

View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}