refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
1 parent
56f1a8175f
commit
8c5b1555c9
285 files changed
+26
-3600
No files matched your search
+414
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ai;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class AI {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
const PROVIDER_NONE = 'none';
|
||||
const PROVIDER_OPENAI = 'openai';
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 配置(主题侧 provider)
|
||||
*/
|
||||
public function get_config(): array {
|
||||
$defaults = [
|
||||
'enabled' => false,
|
||||
'provider' => self::PROVIDER_NONE,
|
||||
'api_key' => '',
|
||||
'api_base' => 'https://api.openai.com/v1',
|
||||
'model' => 'gpt-3.5-turbo',
|
||||
'max_tokens' => 1000,
|
||||
'rate_limit' => 10,
|
||||
];
|
||||
|
||||
$saved = get_option('aksrt_ai_settings', []);
|
||||
if (!is_array($saved)) {
|
||||
$saved = [];
|
||||
}
|
||||
|
||||
return array_merge($defaults, $saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 AI 配置
|
||||
*/
|
||||
public function update_config(array $config): bool {
|
||||
$current = $this->get_config();
|
||||
$merged = array_merge($current, $config);
|
||||
|
||||
// Preserve existing key when client sends the masked placeholder.
|
||||
if (isset($config['api_key']) && $config['api_key'] === '***') {
|
||||
$merged['api_key'] = $current['api_key'];
|
||||
}
|
||||
|
||||
$merged['enabled'] = !empty($merged['enabled']);
|
||||
$merged['provider'] = sanitize_key((string) ($merged['provider'] ?? self::PROVIDER_NONE));
|
||||
$merged['api_key'] = (string) ($merged['api_key'] ?? '');
|
||||
$merged['api_base'] = $this->normalize_api_base((string) ($merged['api_base'] ?? 'https://api.openai.com/v1'));
|
||||
$merged['model'] = sanitize_text_field((string) ($merged['model'] ?? 'gpt-3.5-turbo'));
|
||||
$merged['max_tokens'] = max(100, min(4000, (int) ($merged['max_tokens'] ?? 1000)));
|
||||
$merged['rate_limit'] = max(1, min(100, (int) ($merged['rate_limit'] ?? 10)));
|
||||
|
||||
// Persist only the known keys so shape stays stable.
|
||||
$to_store = [
|
||||
'enabled' => $merged['enabled'],
|
||||
'provider' => $merged['provider'],
|
||||
'api_key' => $merged['api_key'],
|
||||
'api_base' => $merged['api_base'],
|
||||
'model' => $merged['model'],
|
||||
'max_tokens' => $merged['max_tokens'],
|
||||
'rate_limit' => $merged['rate_limit'],
|
||||
];
|
||||
|
||||
$before = get_option('aksrt_ai_settings', []);
|
||||
$updated = update_option('aksrt_ai_settings', $to_store);
|
||||
if ($updated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// update_option returns false when the value is unchanged — treat as success.
|
||||
if (is_array($before)) {
|
||||
$before_normalized = array_merge([
|
||||
'enabled' => false,
|
||||
'provider' => self::PROVIDER_NONE,
|
||||
'api_key' => '',
|
||||
'api_base' => 'https://api.openai.com/v1',
|
||||
'model' => 'gpt-3.5-turbo',
|
||||
'max_tokens' => 1000,
|
||||
'rate_limit' => 10,
|
||||
], $before);
|
||||
$before_normalized['enabled'] = !empty($before_normalized['enabled']);
|
||||
$before_normalized['provider'] = sanitize_key((string) ($before_normalized['provider'] ?? self::PROVIDER_NONE));
|
||||
$before_normalized['api_key'] = (string) ($before_normalized['api_key'] ?? '');
|
||||
$before_normalized['api_base'] = esc_url_raw((string) ($before_normalized['api_base'] ?? 'https://api.openai.com/v1'));
|
||||
$before_normalized['model'] = sanitize_text_field((string) ($before_normalized['model'] ?? 'gpt-3.5-turbo'));
|
||||
$before_normalized['max_tokens'] = max(100, min(4000, (int) ($before_normalized['max_tokens'] ?? 1000)));
|
||||
$before_normalized['rate_limit'] = max(1, min(100, (int) ($before_normalized['rate_limit'] ?? 10)));
|
||||
|
||||
return maybe_serialize($before_normalized) === maybe_serialize($to_store)
|
||||
|| $before_normalized == $to_store;
|
||||
}
|
||||
|
||||
return maybe_serialize($before) === maybe_serialize($to_store);
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress 核心 AI Client 是否真正可用。
|
||||
*
|
||||
* 仅有函数存根不够:没有 Connectors / wp_supports_ai=false 时不应抢占主题 provider。
|
||||
*/
|
||||
public function is_wp_ai_available(): bool {
|
||||
if (!function_exists('wp_ai_client_prompt')) {
|
||||
return false;
|
||||
}
|
||||
// Prefer the official capability gate when present.
|
||||
if (function_exists('wp_supports_ai')) {
|
||||
return (bool) wp_supports_ai();
|
||||
}
|
||||
// Without wp_supports_ai, do not claim availability (avoids false-positive 400s).
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题 OpenAI 兼容 provider 是否可用。
|
||||
*/
|
||||
public function is_theme_provider_available(): bool {
|
||||
$config = $this->get_config();
|
||||
$provider = sanitize_key((string) ($config['provider'] ?? self::PROVIDER_NONE));
|
||||
$api_key = trim((string) ($config['api_key'] ?? ''));
|
||||
$api_base = trim((string) ($config['api_base'] ?? ''));
|
||||
|
||||
return !empty($config['enabled'])
|
||||
&& $provider !== self::PROVIDER_NONE
|
||||
&& $provider !== ''
|
||||
&& $api_key !== ''
|
||||
&& $api_base !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 任一后端可用即可生成。
|
||||
*/
|
||||
public function is_available(): bool {
|
||||
return $this->is_theme_provider_available() || $this->is_wp_ai_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前优先后端标识。
|
||||
*
|
||||
* Theme provider wins when explicitly configured — WP AI stubs often exist
|
||||
* without a working connector and previously produced opaque 400 failures.
|
||||
*/
|
||||
public function resolve_backend(): string {
|
||||
if ($this->is_theme_provider_available()) {
|
||||
return 'theme';
|
||||
}
|
||||
if ($this->is_wp_ai_available()) {
|
||||
return 'wp_ai';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 状态(不回显完整密钥)。
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : [];
|
||||
|
||||
return [
|
||||
'enabled' => !empty($config['enabled']),
|
||||
'provider' => (string) ($config['provider'] ?? self::PROVIDER_NONE),
|
||||
'configured' => !empty($config['api_key']) || $this->is_wp_ai_available(),
|
||||
'model' => (string) ($config['model'] ?? ''),
|
||||
'api_base' => (string) ($config['api_base'] ?? ''),
|
||||
'api_key' => !empty($config['api_key']) ? '***' : '',
|
||||
'max_tokens' => (int) ($config['max_tokens'] ?? 1000),
|
||||
'rate_limit' => (int) ($config['rate_limit'] ?? 10),
|
||||
'available' => $this->is_available(),
|
||||
'backend' => $this->resolve_backend(),
|
||||
'wp_ai_available' => $this->is_wp_ai_available(),
|
||||
'theme_available' => $this->is_theme_provider_available(),
|
||||
'seo_enabled' => !empty($seo['seo_enabled']),
|
||||
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']),
|
||||
'write_assist' => $this->is_available(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一文本生成出口。
|
||||
*
|
||||
* @param string $prompt 用户提示 / 上下文
|
||||
* @param array $options system_instruction, max_tokens, temperature
|
||||
* @return array{success:bool,content?:string,message?:string,backend?:string}
|
||||
*/
|
||||
public function generate_text(string $prompt, array $options = []): array {
|
||||
if (!$this->is_available()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->check_rate_limit()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('请求过于频繁,请稍后再试', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
$system = isset($options['system_instruction'])
|
||||
? (string) $options['system_instruction']
|
||||
: __('你是一个内容助手,提供建议性内容。输出仅作为参考建议,不会自动应用到线上内容。', 'aksrt');
|
||||
|
||||
$last_error = [
|
||||
'success' => false,
|
||||
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'),
|
||||
];
|
||||
|
||||
// 1) Prefer explicitly configured theme OpenAI-compatible provider.
|
||||
if ($this->is_theme_provider_available()) {
|
||||
$theme_result = $this->generate_via_theme($prompt, $system, $options);
|
||||
$this->record_call();
|
||||
if (!empty($theme_result['success'])) {
|
||||
$theme_result['backend'] = 'theme';
|
||||
return $theme_result;
|
||||
}
|
||||
$last_error = $theme_result;
|
||||
// Fall through to WP AI if available.
|
||||
}
|
||||
|
||||
// 2) WordPress AI Client (Connectors).
|
||||
if ($this->is_wp_ai_available()) {
|
||||
$wp_result = $this->generate_via_wp_ai($prompt, $system, $options);
|
||||
$this->record_call();
|
||||
if (!empty($wp_result['success'])) {
|
||||
$wp_result['backend'] = 'wp_ai';
|
||||
return $wp_result;
|
||||
}
|
||||
$last_error = $wp_result;
|
||||
}
|
||||
|
||||
return $last_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧调用。
|
||||
*/
|
||||
public function generate(string $prompt, array $options = []): array {
|
||||
return $this->generate_text($prompt, $options);
|
||||
}
|
||||
|
||||
private function generate_via_wp_ai(string $prompt, string $system, array $options): array {
|
||||
try {
|
||||
$builder = wp_ai_client_prompt($prompt);
|
||||
if (is_object($builder) && method_exists($builder, 'using_system_instruction') && $system !== '') {
|
||||
$builder = $builder->using_system_instruction($system);
|
||||
}
|
||||
if (!is_object($builder) || !method_exists($builder, 'generate_text')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('WordPress AI Client 接口不可用', 'aksrt'),
|
||||
];
|
||||
}
|
||||
$text = $builder->generate_text();
|
||||
if (is_wp_error($text)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $text->get_error_message(),
|
||||
];
|
||||
}
|
||||
$text = is_string($text) ? trim($text) : '';
|
||||
if ($text === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI 未返回任何文本', 'aksrt'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $text,
|
||||
];
|
||||
} catch (\Throwable $t) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $t->getMessage() ?: __('WordPress AI Client 调用失败', 'aksrt'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OpenAI-compatible base URL.
|
||||
*
|
||||
* Accepts gateway root (https://host), /v1, trailing slash, or full
|
||||
* .../chat/completions and returns a base that can append /chat/completions.
|
||||
*/
|
||||
public function normalize_api_base(string $api_base): string {
|
||||
$api_base = trim($api_base);
|
||||
if ($api_base === '') {
|
||||
return 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
$api_base = esc_url_raw($api_base);
|
||||
$api_base = rtrim($api_base, '/');
|
||||
|
||||
// If user pasted the full chat endpoint, strip the leaf.
|
||||
if (preg_match('#/chat/completions$#i', $api_base)) {
|
||||
$api_base = (string) preg_replace('#/chat/completions$#i', '', $api_base);
|
||||
$api_base = rtrim($api_base, '/');
|
||||
}
|
||||
|
||||
// Common gateways (NewAPI etc.): root host needs /v1.
|
||||
if (!preg_match('#/(v1|openai/v1)$#i', $api_base)) {
|
||||
$api_base .= '/v1';
|
||||
}
|
||||
|
||||
return rtrim($api_base, '/');
|
||||
}
|
||||
|
||||
private function generate_via_theme(string $prompt, string $system, array $options): array {
|
||||
$config = $this->get_config();
|
||||
$max_tokens = (int) ($options['max_tokens'] ?? $config['max_tokens']);
|
||||
$temperature = (float) ($options['temperature'] ?? 0.7);
|
||||
$api_base = $this->normalize_api_base((string) $config['api_base']);
|
||||
|
||||
$response = wp_remote_post($api_base . '/chat/completions', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $config['api_key'],
|
||||
],
|
||||
'body' => wp_json_encode([
|
||||
'model' => $config['model'],
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
'max_tokens' => $max_tokens,
|
||||
'temperature' => $temperature,
|
||||
]),
|
||||
'timeout' => 45,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => sprintf(
|
||||
/* translators: %s: error message */
|
||||
__('API 请求失败: %s', 'aksrt'),
|
||||
$response->get_error_message()
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$body = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
$status_code = (int) wp_remote_retrieve_response_code($response);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$error_msg = is_array($body) ? ($body['error']['message'] ?? __('未知错误', 'aksrt')) : __('未知错误', 'aksrt');
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => sprintf(
|
||||
/* translators: 1: HTTP status, 2: error message */
|
||||
__('API 错误 (%1$d): %2$s', 'aksrt'),
|
||||
$status_code,
|
||||
$error_msg
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$message = is_array($body) ? ($body['choices'][0]['message'] ?? []) : [];
|
||||
$content = is_array($message) ? (string) ($message['content'] ?? '') : '';
|
||||
|
||||
if ($content === '' && is_array($message) && !empty($message['reasoning_content'])) {
|
||||
$content = (string) $message['reasoning_content'];
|
||||
}
|
||||
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI 返回内容为空(可能 max_tokens 不足)', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $content,
|
||||
'reasoning' => is_array($message) ? (string) ($message['reasoning_content'] ?? '') : '',
|
||||
'usage' => is_array($body) ? ($body['usage'] ?? []) : [],
|
||||
'finish_reason' => is_array($body) ? (string) ($body['choices'][0]['finish_reason'] ?? '') : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function check_rate_limit(): bool {
|
||||
$config = $this->get_config();
|
||||
$limit = (int) $config['rate_limit'];
|
||||
if ($limit <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$key = 'aksrt_ai_rate_' . get_current_user_id();
|
||||
$count = (int) get_transient($key);
|
||||
|
||||
return $count < $limit;
|
||||
}
|
||||
|
||||
private function record_call(): void {
|
||||
$key = 'aksrt_ai_rate_' . get_current_user_id();
|
||||
$count = (int) get_transient($key);
|
||||
set_transient($key, $count + 1, MINUTE_IN_SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ai;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class AIPrompt {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 标题 system instruction(Zibll 默认长提示词)。
|
||||
*/
|
||||
public function get_seo_title_system_instruction(): string {
|
||||
$custom = $this->read_custom_prompt('ai_seo_title_prompt');
|
||||
if ($custom !== '') {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
return '你是一名资深的网站 SEO 编辑,擅长撰写既符合搜索习惯又具备点击力的文章标题。
|
||||
请严格遵循以下规则:
|
||||
1. 输出 1 条最佳标题,作为该文章的 <title>。
|
||||
2. 字数控制在 20 至 40 个汉字之间,避免过短或过长。
|
||||
3. 必须包含文章的主关键词,且把最重要的关键词放在标题前部。
|
||||
4. 不要堆砌关键词、不要重复同义词、不要使用煽动性或夸张词汇。
|
||||
5. 不要包含网站名称、品牌后缀,我们会自动追加站点名。
|
||||
6. 直接输出标题文本本身,不要任何前缀、引号、Markdown、序号或解释。
|
||||
7. 不要输出"标题:"、"答:"、"以下是" 等任何前导词。';
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 关键词 system instruction。
|
||||
*/
|
||||
public function get_seo_keywords_system_instruction(): string {
|
||||
$custom = $this->read_custom_prompt('ai_seo_keywords_prompt');
|
||||
if ($custom !== '') {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
return '你是一名资深 SEO 编辑,负责为网站生成文章 meta keywords。
|
||||
请严格遵循以下规则:
|
||||
1. 输出 4 到 8 个最具搜索价值的关键词。
|
||||
2. 全部使用中文(专有英文词可保留原文,例如 WordPress、AI、SEO)。
|
||||
3. 使用英文逗号"," 分隔,不要使用顿号、句号、换行或编号。
|
||||
4. 第一个关键词必须是文章的主关键词,紧扣标题与正文核心主题。
|
||||
5. 不要堆砌、不要重复同义词、不要出现"等"、"以及"等填充语。
|
||||
6. 直接输出关键词列表,不要任何前缀、解释、Markdown、引号或括号。
|
||||
7. 关键词总长度建议控制在 50 个汉字以内。';
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 描述 system instruction。
|
||||
*/
|
||||
public function get_seo_description_system_instruction(): string {
|
||||
$custom = $this->read_custom_prompt('ai_seo_description_prompt');
|
||||
if ($custom !== '') {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
return '你是一名资深的中文 SEO 编辑,擅长撰写吸引点击的搜索结果摘要(meta description)。
|
||||
请严格遵循以下规则:
|
||||
1. 输出 1 段描述,作为该文章的 meta description。
|
||||
2. 字数控制在 80 至 150 个汉字之间,写成1至4句通顺的中文。
|
||||
3. 必须自然融入文章的主关键词,但不要堆砌。
|
||||
4. 体现文章的核心价值、目标读者或可获得的收益,要有吸引力但不夸张。
|
||||
5. 不要分行、不要使用项目符号、不要使用 Markdown,不要包含品牌后缀或网址。
|
||||
6. 直接输出描述文本本身,不要任何前缀、引号或解释。';
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧单 prompt 构建(草稿/无 ID 场景)。
|
||||
*/
|
||||
public function build_seo_prompt(string $title, string $content): string {
|
||||
$title_instruction = $this->get_seo_title_system_instruction();
|
||||
$kw_instruction = $this->get_seo_keywords_system_instruction();
|
||||
$desc_instruction = $this->get_seo_description_system_instruction();
|
||||
|
||||
return <<<PROMPT
|
||||
请根据以下文章内容,生成 SEO 优化建议。
|
||||
|
||||
文章标题:{$title}
|
||||
|
||||
文章内容:
|
||||
{$content}
|
||||
|
||||
请以 JSON 格式返回,包含以下字段:
|
||||
- suggested_title: {$title_instruction}
|
||||
- suggested_description: {$desc_instruction}
|
||||
- suggested_keywords: {$kw_instruction}(以 JSON 数组形式返回)
|
||||
|
||||
只返回 JSON,不要其他内容。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
public function build_summary_prompt(string $content, int $max_length = 200): string {
|
||||
$content = mb_substr($content, 0, 3000);
|
||||
return <<<PROMPT
|
||||
请为以下内容生成一段简洁的摘要,不超过 {$max_length} 个字。
|
||||
|
||||
内容:
|
||||
{$content}
|
||||
|
||||
只返回摘要文本,不要其他内容。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
public function build_tags_prompt(string $title, string $content): string {
|
||||
$content = mb_substr($content, 0, 2000);
|
||||
return <<<PROMPT
|
||||
请根据以下文章内容,建议合适的标签。
|
||||
|
||||
文章标题:{$title}
|
||||
|
||||
文章内容:
|
||||
{$content}
|
||||
|
||||
请以 JSON 数组格式返回标签列表(5-10个),例如:["标签1", "标签2"]
|
||||
只返回 JSON 数组,不要其他内容。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
public function build_excerpt_prompt(string $title, string $content): string {
|
||||
$content = mb_substr($content, 0, 2000);
|
||||
return <<<PROMPT
|
||||
请为以下文章生成一段吸引人的摘要(excerpt),用于列表页展示。
|
||||
|
||||
文章标题:{$title}
|
||||
|
||||
文章内容:
|
||||
{$content}
|
||||
|
||||
要求:
|
||||
- 50-100字
|
||||
- 突出文章核心价值
|
||||
- 吸引读者点击
|
||||
|
||||
只返回摘要文本,不要其他内容。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function read_custom_prompt(string $key): string {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return '';
|
||||
}
|
||||
$s = \AKSRT\Settings\Settings::get_all();
|
||||
return trim((string) ($s[$key] ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ai;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class AISEO {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO AI 功能是否启用。
|
||||
*/
|
||||
public function is_enabled(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return false;
|
||||
}
|
||||
$seo = \AKSRT\Settings\Settings::get_seo_settings();
|
||||
return !empty($seo['seo_enabled']) && !empty($seo['seo_ai_enabled']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已保存实体生成 SEO 三字段(Zibll 形状)。
|
||||
*
|
||||
* @return array{success:bool,data?:array,message?:string}
|
||||
*/
|
||||
public function generate_fields(string $type, int $id): array {
|
||||
if (!$this->is_enabled()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI SEO 功能未启用', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!class_exists('AKSRT\\Ai\\AI') || !class_exists('AKSRT\\Ai\\AIPrompt')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI 模块未加载', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
$ai = AI::get_instance();
|
||||
if (!$ai->is_available()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI 服务未启用或未配置', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
$type = $type === 'term' ? 'term' : 'post';
|
||||
if ($id <= 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('请先保存后再生成 SEO', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
$context = $type === 'term'
|
||||
? $this->build_term_context($id)
|
||||
: $this->build_post_context($id);
|
||||
|
||||
if (is_wp_error($context)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $context->get_error_message(),
|
||||
];
|
||||
}
|
||||
|
||||
$prompt = AIPrompt::get_instance();
|
||||
|
||||
$title = $this->run_field(
|
||||
$ai,
|
||||
$context,
|
||||
$prompt->get_seo_title_system_instruction(),
|
||||
'title'
|
||||
);
|
||||
$keywords = $this->run_field(
|
||||
$ai,
|
||||
$context,
|
||||
$prompt->get_seo_keywords_system_instruction(),
|
||||
'keywords'
|
||||
);
|
||||
$description = $this->run_field(
|
||||
$ai,
|
||||
$context,
|
||||
$prompt->get_seo_description_system_instruction(),
|
||||
'description'
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'title' => $title,
|
||||
'keywords' => $keywords,
|
||||
'description' => $description,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 草稿场景:用传入 title/content 生成(兼容旧 /ai/seo-suggest)。
|
||||
*/
|
||||
public function suggest(string $title, string $content = ''): array {
|
||||
if (!$this->is_enabled()) {
|
||||
return ['success' => false, 'message' => __('AI SEO 功能未启用', 'aksrt')];
|
||||
}
|
||||
|
||||
if (!class_exists('AKSRT\\Ai\\AI') || !class_exists('AKSRT\\Ai\\AIPrompt')) {
|
||||
return ['success' => false, 'message' => __('AI 模块未加载', 'aksrt')];
|
||||
}
|
||||
|
||||
$ai = AI::get_instance();
|
||||
if (!$ai->is_available()) {
|
||||
return ['success' => false, 'message' => __('AI 服务未启用', 'aksrt')];
|
||||
}
|
||||
|
||||
$title = trim($title);
|
||||
$content = trim(wp_strip_all_tags($content));
|
||||
if ($title === '' && $content === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('无可用内容,请先填写标题或正文', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($title !== '') {
|
||||
$parts[] = '标题: ' . $title;
|
||||
}
|
||||
if ($content !== '') {
|
||||
$content = wp_trim_words($content, 1500, '');
|
||||
$parts[] = "正文:\n" . $content;
|
||||
}
|
||||
$context = implode("\n\n", $parts);
|
||||
$prompt = AIPrompt::get_instance();
|
||||
|
||||
$title_out = $this->run_field($ai, $context, $prompt->get_seo_title_system_instruction(), 'title');
|
||||
$kw_out = $this->run_field($ai, $context, $prompt->get_seo_keywords_system_instruction(), 'keywords');
|
||||
$desc_out = $this->run_field($ai, $context, $prompt->get_seo_description_system_instruction(), 'description');
|
||||
|
||||
$keywords = [];
|
||||
if (!empty($kw_out['content'])) {
|
||||
$keywords = array_values(array_filter(array_map('trim', explode(',', $kw_out['content']))));
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'suggested_title' => (string) ($title_out['content'] ?? ''),
|
||||
'suggested_description' => (string) ($desc_out['content'] ?? ''),
|
||||
'suggested_keywords' => $keywords,
|
||||
'fields' => [
|
||||
'title' => $title_out,
|
||||
'keywords' => $kw_out,
|
||||
'description' => $desc_out,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
public function build_post_context(int $post_id, int $word_limit = 1500) {
|
||||
$post = get_post($post_id);
|
||||
if (!$post) {
|
||||
return new \WP_Error('no_post', __('内容不存在', 'aksrt'));
|
||||
}
|
||||
|
||||
$title = trim((string) $post->post_title);
|
||||
$excerpt = trim((string) $post->post_excerpt);
|
||||
$content = wp_strip_all_tags((string) $post->post_content);
|
||||
$content = trim(preg_replace('/\s+/u', ' ', $content) ?? $content);
|
||||
$content = wp_trim_words($content, max(50, $word_limit), '');
|
||||
|
||||
if ($title === '' && $content === '' && $excerpt === '') {
|
||||
return new \WP_Error('empty_content', __('无可用内容,请先填写标题、摘要或正文并保存', 'aksrt'));
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($title !== '') {
|
||||
$parts[] = '标题: ' . $title;
|
||||
}
|
||||
if ($excerpt !== '') {
|
||||
$parts[] = '摘要: ' . $excerpt;
|
||||
}
|
||||
if ($content !== '') {
|
||||
$parts[] = "正文:\n" . $content;
|
||||
}
|
||||
|
||||
return implode("\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
public function build_term_context(int $term_id) {
|
||||
$term = get_term($term_id);
|
||||
if (!$term || is_wp_error($term)) {
|
||||
return new \WP_Error('no_term', __('分类/标签不存在', 'aksrt'));
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($term->name !== '') {
|
||||
$parts[] = '标题: ' . $term->name;
|
||||
}
|
||||
if ($term->description !== '') {
|
||||
$parts[] = '描述: ' . wp_strip_all_tags((string) $term->description);
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return new \WP_Error('empty_term', __('没有可用的标题或描述', 'aksrt'));
|
||||
}
|
||||
|
||||
return implode("\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content?:string,error?:string}
|
||||
*/
|
||||
private function run_field(AI $ai, string $context, string $system, string $field): array {
|
||||
$result = $ai->generate_text($context, [
|
||||
'system_instruction' => $system,
|
||||
'max_tokens' => $field === 'description' ? 400 : 300,
|
||||
'temperature' => 0.5,
|
||||
]);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return [
|
||||
'error' => (string) ($result['message'] ?? __('生成失败', 'aksrt')),
|
||||
];
|
||||
}
|
||||
|
||||
$raw = (string) ($result['content'] ?? '');
|
||||
return [
|
||||
'content' => $this->post_process_field($field, $raw),
|
||||
];
|
||||
}
|
||||
|
||||
private function post_process_field(string $field, string $raw): string {
|
||||
$raw = trim($raw);
|
||||
// Strip common wrappers
|
||||
$raw = preg_replace('/^["\'“”]+|["\'“”]+$/u', '', $raw) ?? $raw;
|
||||
$raw = trim($raw);
|
||||
|
||||
if ($field === 'title') {
|
||||
$title = sanitize_text_field($raw);
|
||||
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : [];
|
||||
$conn = trim((string) ($seo['seo_connector'] ?? '-')) ?: '-';
|
||||
$site = trim((string) get_bloginfo('name'));
|
||||
if ($site !== '' && $title !== '' && mb_stripos($title, $site) === false) {
|
||||
$title = $title . ' ' . $conn . ' ' . $site;
|
||||
}
|
||||
return $title;
|
||||
}
|
||||
|
||||
if ($field === 'keywords') {
|
||||
$raw = str_replace(['、', ',', ';', ';', "\n", "\r"], ',', $raw);
|
||||
$parts = array_map('trim', explode(',', $raw));
|
||||
$parts = array_values(array_unique(array_filter($parts, static function ($p) {
|
||||
return $p !== '';
|
||||
})));
|
||||
$parts = array_map('sanitize_text_field', $parts);
|
||||
return implode(',', array_slice($parts, 0, 12));
|
||||
}
|
||||
|
||||
// description
|
||||
$raw = preg_replace('/\s+/u', ' ', $raw) ?? $raw;
|
||||
return sanitize_text_field(trim($raw));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user