diff --git a/assets/admin/src/api/modules/extensions.js b/assets/admin/src/api/modules/extensions.js index 5765f40c..36c98474 100644 --- a/assets/admin/src/api/modules/extensions.js +++ b/assets/admin/src/api/modules/extensions.js @@ -6,7 +6,10 @@ export const extensionsApiMethods = { testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) }, testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) }, - /* AI 辅助(兼容页) */ + /* AI 辅助 */ getAiStatus() { return this.request('/ai/status') }, updateAiSettings(data) { return this.request('/ai/settings', { method: 'POST', data }) }, + generateSeo(data) { return this.request('/ai/seo-generate', { method: 'POST', data }) }, + suggestSummary(data) { return this.request('/ai/summary-suggest', { method: 'POST', data }) }, + suggestTags(data) { return this.request('/ai/tags-suggest', { method: 'POST', data }) }, } diff --git a/assets/admin/src/constants/sections/extensions.js b/assets/admin/src/constants/sections/extensions.js index 0456e636..cacf678c 100644 --- a/assets/admin/src/constants/sections/extensions.js +++ b/assets/admin/src/constants/sections/extensions.js @@ -64,17 +64,15 @@ export const extensionsSection = { title: '系统工具', desc: '额外运维工具与扩展能力。', }), - // AI 能力属停用候选(stream-a/§5.9),不进入核心 schema:使用自有 /ai/* REST 端点, - // 不声明 canonicalDomain/section,避免 SchemaPage 契约指向不存在的 schema section。 customPage({ key: 'extensions-ai', name: 'ExtensionAI', - label: 'AI 辅助(兼容)', + label: 'AI 连接配置', route: '/extensions/ai', component: 'ExtensionAI', status: 'available', - title: 'AI 辅助(兼容)', - desc: '兼容保留 AI 内容辅助能力,不作为主线扩展能力入口。', + title: 'AI 连接配置', + desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。', }), ], } diff --git a/assets/admin/src/pages/extensions/ExtensionAI.vue b/assets/admin/src/pages/extensions/ExtensionAI.vue index 56432dcb..93943e72 100644 --- a/assets/admin/src/pages/extensions/ExtensionAI.vue +++ b/assets/admin/src/pages/extensions/ExtensionAI.vue @@ -1,65 +1,63 @@ @@ -257,31 +263,12 @@ export default { justify-content: center; font-size: 16px; } -.aksrt-ai__status-icon--ok { - background: #d4edda; - color: #155724; -} -.aksrt-ai__status-icon--warn { - background: #fff3cd; - color: #856404; -} -.aksrt-ai__status-icon--off { - background: #f8d7da; - color: #721c24; -} -.aksrt-ai__status-icon--info { - background: #d1ecf1; - color: #0c5460; -} -.aksrt-ai__status-value { - font-size: 18px; - font-weight: 600; - color: #1a1a2e; -} -.aksrt-ai__status-label { - font-size: 12px; - color: #888; -} +.aksrt-ai__status-icon--ok { background: #d4edda; color: #155724; } +.aksrt-ai__status-icon--warn { background: #fff3cd; color: #856404; } +.aksrt-ai__status-icon--off { background: #f8d7da; color: #721c24; } +.aksrt-ai__status-icon--info { background: #d1ecf1; color: #0c5460; } +.aksrt-ai__status-value { font-size: 18px; font-weight: 600; color: #1a1a2e; } +.aksrt-ai__status-label { font-size: 12px; color: #888; } .aksrt-ai__form-card { background: #fff; border: 1px solid #e8e8e8; @@ -289,41 +276,32 @@ export default { padding: 24px; margin-bottom: 16px; } -.aksrt-ai__form-card h3 { - margin: 0 0 16px; - font-size: 16px; - font-weight: 600; -} -.aksrt-form-hint { - font-size: 12px; - color: #888; - margin-top: 4px; -} +.aksrt-ai__form-card h3 { margin: 0 0 16px; font-size: 16px; font-weight: 600; } +.aksrt-form-hint { font-size: 12px; color: #888; margin-top: 4px; } .aksrt-ai__load-error { background: #fdecea; border-color: #f5c2c0; color: #842029; margin-bottom: 16px; } -.aksrt-ai__notice { - background: #fff8e1; - border-color: #ffe082; -} -.aksrt-ai__notice h3 { - color: #e65100; -} -.aksrt-ai__notice ul { - margin: 0; - padding-left: 20px; -} -.aksrt-ai__notice li { - margin-bottom: 8px; - font-size: 14px; -} +.aksrt-ai__notice { background: #fff8e1; border-color: #ffe082; } +.aksrt-ai__notice h3 { color: #e65100; } +.aksrt-ai__notice ul { margin: 0; padding-left: 20px; } +.aksrt-ai__notice li { margin-bottom: 8px; font-size: 14px; } .aksrt-ai__notice code { background: rgba(0,0,0,0.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; } +.aksrt-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; } +.aksrt-badge--success { background: #d4edda; color: #155724; } +.aksrt-badge--warning { background: #fff3cd; color: #856404; } +.aksrt-table { width: 100%; border-collapse: collapse; } +.aksrt-table th, .aksrt-table td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; } +.aksrt-form-group { margin-bottom: 14px; } +.aksrt-input, .aksrt-select { width: 100%; max-width: 480px; padding: 8px 10px; } +.aksrt-button { padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer; } +.aksrt-button--primary { background: #1677ff; color: #fff; } +.aksrt-button:disabled { opacity: .6; cursor: not-allowed; } diff --git a/assets/js/admin-ai-seo.js b/assets/js/admin-ai-seo.js new file mode 100644 index 00000000..f358bc70 --- /dev/null +++ b/assets/js/admin-ai-seo.js @@ -0,0 +1,130 @@ +/** + * AKSRT Admin AI SEO — generate button for post/term SEO fields + */ +(function () { + 'use strict'; + + if (!window.aksrtAiSeo) return; + + var CFG = window.aksrtAiSeo; + var REST = (CFG.restUrl || '/wp-json/aksrt/v1').replace(/\/$/, ''); + var NONCE = CFG.nonce || ''; + var I18N = CFG.i18n || {}; + + function notify($btn, msg, type) { + var wrap = $btn.closest('.aksrt-seo-meta-box, td, .form-field, p') || $btn.parentNode; + if (!wrap) return; + var old = wrap.querySelector('.aksrt-ai-seo-msg'); + if (old) old.remove(); + var el = document.createElement('span'); + el.className = 'aksrt-ai-seo-msg ' + (type === 'error' ? 'is-error' : 'is-ok'); + el.textContent = msg; + wrap.appendChild(el); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + }, 8000); + } + + function fillPost(data) { + var map = { + title: 'aksrt_seo_title', + keywords: 'aksrt_seo_keywords', + description: 'aksrt_seo_description', + }; + Object.keys(map).forEach(function (key) { + var field = data[key] || {}; + if (!field.content) return; + var input = document.getElementById(map[key]) || document.querySelector('[name="' + map[key] + '"]'); + if (input) input.value = field.content; + }); + } + + function fillTerm(data) { + var map = { + title: '.aksrt-term-seo-title, #aksrt_seo_title', + keywords: '.aksrt-term-seo-keywords, #aksrt_seo_keywords', + description: '.aksrt-term-seo-description, #aksrt_seo_description', + }; + Object.keys(map).forEach(function (key) { + var field = data[key] || {}; + if (!field.content) return; + var input = document.querySelector(map[key]); + if (input) input.value = field.content; + }); + } + + function collectErrors(data) { + var errors = []; + ['title', 'keywords', 'description'].forEach(function (key) { + if (data[key] && data[key].error) errors.push(data[key].error); + }); + return errors; + } + + document.addEventListener('click', function (e) { + var btn = e.target.closest('.aksrt-ai-seo-generate'); + if (!btn) return; + e.preventDefault(); + + if (btn.classList.contains('is-loading') || btn.disabled) return; + + var type = btn.getAttribute('data-type') || 'post'; + var id = parseInt(btn.getAttribute('data-id') || '0', 10); + if (!id) { + notify(btn, I18N.needSave || '请先保存后再生成 SEO', 'error'); + return; + } + if (CFG.ai && CFG.ai.available === false) { + notify(btn, I18N.notAvailable || 'AI 未配置或未启用', 'error'); + return; + } + + var original = btn.textContent; + btn.classList.add('is-loading'); + btn.textContent = I18N.generating || '生成中…'; + + fetch(REST + '/ai/seo-generate', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': NONCE, + }, + body: JSON.stringify({ type: type, id: id }), + }) + .then(function (r) { + return r.json().then(function (json) { + return { ok: r.ok, json: json }; + }); + }) + .then(function (res) { + btn.classList.remove('is-loading'); + btn.textContent = original; + + if (!res.ok || !res.json || res.json.success === false) { + var msg = + (res.json && (res.json.message || (res.json.data && res.json.data.message))) || + I18N.failed || + '生成失败'; + notify(btn, msg, 'error'); + return; + } + + var data = res.json.data || {}; + if (type === 'term') fillTerm(data); + else fillPost(data); + + var errors = collectErrors(data); + if (errors.length) { + notify(btn, errors.join(';'), 'error'); + } else { + notify(btn, I18N.success || '已填入 SEO 字段,请检查后更新保存', 'ok'); + } + }) + .catch(function () { + btn.classList.remove('is-loading'); + btn.textContent = original; + notify(btn, I18N.failed || '生成失败', 'error'); + }); + }); +})(); diff --git a/assets/js/front-submit.js b/assets/js/front-submit.js index 09f64f79..c974ddf5 100644 --- a/assets/js/front-submit.js +++ b/assets/js/front-submit.js @@ -13,6 +13,9 @@ var i18n = (window.aksrtSubmitData && window.aksrtSubmitData.i18n) || {}; var ajaxUrl = (window.aksrtSubmitData && window.aksrtSubmitData.ajaxUrl) || '/wp-admin/admin-ajax.php'; var nonce = (window.aksrtSubmitData && window.aksrtSubmitData.nonce) || ''; + var restUrl = ((window.aksrtSubmitData && window.aksrtSubmitData.restUrl) || '/wp-json/aksrt/v1').replace(/\/$/, ''); + var restNonce = (window.aksrtSubmitData && window.aksrtSubmitData.restNonce) || ''; + var aiCfg = (window.aksrtSubmitData && window.aksrtSubmitData.ai) || {}; var FRONT_SUBMIT = { form: null, @@ -31,6 +34,7 @@ this.bindCoverPreview(); this.bindWordCount(); this.bindLeaveWarning(); + this.bindAiAssist(); }, /** @@ -351,6 +355,112 @@ contentEl.addEventListener('input', updateCount); updateCount(); }, + + /** + * AI 摘要 / 标签辅助(仅建议回填,不自动提交) + */ + bindAiAssist: function () { + var self = this; + if (!aiCfg.available) return; + + var buttons = this.form.querySelectorAll('.aksrt-ai-assist-btn'); + if (!buttons.length) return; + + buttons.forEach(function (btn) { + btn.hidden = false; + btn.addEventListener('click', function (e) { + e.preventDefault(); + var action = btn.getAttribute('data-ai-action'); + if (action === 'summary') self.runAiSummary(btn); + if (action === 'tags') self.runAiTags(btn); + }); + }); + }, + + getSubmitContent: function () { + var titleEl = document.getElementById('submit-title'); + var contentEl = document.getElementById('submit-content'); + return { + title: titleEl ? titleEl.value.trim() : '', + content: contentEl ? contentEl.value.trim() : '', + }; + }, + + runAiSummary: function (btn) { + var data = this.getSubmitContent(); + if (!data.content) { + alert(i18n.needContent || '请先填写正文内容'); + return; + } + this.aiRequest(btn, '/ai/summary-suggest', { + title: data.title, + content: data.content, + max_length: 200, + }, function (payload) { + var summary = payload && payload.summary ? String(payload.summary) : ''; + var el = document.getElementById('submit-excerpt'); + if (el && summary) { + el.value = summary; + el.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + }, + + runAiTags: function (btn) { + var data = this.getSubmitContent(); + if (!data.content && !data.title) { + alert(i18n.needContent || '请先填写正文内容'); + return; + } + this.aiRequest(btn, '/ai/tags-suggest', { + title: data.title, + content: data.content, + }, function (payload) { + var tags = payload && Array.isArray(payload.tags) ? payload.tags : []; + var el = document.getElementById('submit-tags'); + if (el && tags.length) { + el.value = tags.join(', '); + el.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + }, + + aiRequest: function (btn, path, body, onSuccess) { + if (btn.getAttribute('data-loading') === '1') return; + var original = btn.textContent; + btn.setAttribute('data-loading', '1'); + btn.textContent = i18n.aiGenerating || '生成中…'; + + fetch(restUrl + path, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': restNonce, + }, + body: JSON.stringify(body || {}), + }) + .then(function (r) { + return r.json().then(function (json) { + return { ok: r.ok, json: json }; + }); + }) + .then(function (res) { + btn.removeAttribute('data-loading'); + btn.textContent = original; + if (!res.ok || !res.json || res.json.success === false) { + var msg = (res.json && res.json.message) || i18n.aiFailed || 'AI 生成失败'; + alert(msg); + return; + } + onSuccess(res.json.data || {}); + }) + .catch(function () { + btn.removeAttribute('data-loading'); + btn.textContent = original; + alert(i18n.aiFailed || 'AI 生成失败'); + }); + }, }; if (document.readyState === 'loading') { diff --git a/functions.php b/functions.php index 3ffd3907..b87578bd 100644 --- a/functions.php +++ b/functions.php @@ -294,6 +294,9 @@ if (class_exists('AKSRT_Product_Review')) { if (is_admin() && class_exists('AKSRT_Post_Meta_Box')) { AKSRT_Post_Meta_Box::get_instance()->init(); } +if (is_admin() && class_exists('AKSRT_SEO_Meta_Box')) { + AKSRT_SEO_Meta_Box::get_instance()->init(); +} // 绔欑偣璁块棶閲忕粺璁? if (class_exists('AKSRT_Stats_Tracker')) { diff --git a/inc/bootstrap.php b/inc/bootstrap.php index beade4c6..88b71e30 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -335,6 +335,7 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-ai.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-ai-seo.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-ai-prompt.php'; require_once AKSRT_DIR . '/inc/classes/admin/class-aksrt-admin-ai-controller.php'; +require_once AKSRT_DIR . '/inc/classes/class-aksrt-seo-meta-box.php'; // SEO head 单一装配器(站点/单篇 SEO、OG/Twitter、JSON-LD、canonical、robots) require_once AKSRT_DIR . '/inc/classes/class-aksrt-seo-head.php'; diff --git a/inc/classes/admin/class-aksrt-admin-ai-controller.php b/inc/classes/admin/class-aksrt-admin-ai-controller.php index 65cec3dc..801890e0 100644 --- a/inc/classes/admin/class-aksrt-admin-ai-controller.php +++ b/inc/classes/admin/class-aksrt-admin-ai-controller.php @@ -2,7 +2,7 @@ /** * AKSRT Admin — AI Controller * - * Owns REST routes for the "AI 子系统" domain. + * REST for AI connection config + SEO/writing generation. * * @package AKSRT */ @@ -14,50 +14,70 @@ class AKSRT_Admin_AI_Controller extends AKSRT_Admin_Controller_Base { private static $instance = null; public static function get_instance(): self { - if (null === self::$instance) self::$instance = new self(); + if (null === self::$instance) { + self::$instance = new self(); + } return self::$instance; } public function register_routes(): void { - // AI status register_rest_route(self::API_NAMESPACE, '/ai/status', [ 'methods' => 'GET', 'callback' => [$this, 'get_status'], - 'permission_callback' => [$this, 'check_permission'], + 'permission_callback' => [$this, 'check_logged_in'], ]); - // AI settings update register_rest_route(self::API_NAMESPACE, '/ai/settings', [ 'methods' => 'POST', 'callback' => [$this, 'update_settings'], 'permission_callback' => [$this, 'check_permission'], ]); - // AI SEO suggest + register_rest_route(self::API_NAMESPACE, '/ai/seo-generate', [ + 'methods' => 'POST', + 'callback' => [$this, 'seo_generate'], + 'permission_callback' => [$this, 'check_logged_in'], + ]); + register_rest_route(self::API_NAMESPACE, '/ai/seo-suggest', [ 'methods' => 'POST', 'callback' => [$this, 'seo_suggest'], - 'permission_callback' => [$this, 'check_permission'], + 'permission_callback' => [$this, 'check_write_assist'], ]); - // AI summary suggest register_rest_route(self::API_NAMESPACE, '/ai/summary-suggest', [ 'methods' => 'POST', 'callback' => [$this, 'summary_suggest'], - 'permission_callback' => [$this, 'check_permission'], + 'permission_callback' => [$this, 'check_write_assist'], ]); - // AI tags suggest register_rest_route(self::API_NAMESPACE, '/ai/tags-suggest', [ 'methods' => 'POST', 'callback' => [$this, 'tags_suggest'], - 'permission_callback' => [$this, 'check_permission'], + 'permission_callback' => [$this, 'check_write_assist'], ]); } + public function check_logged_in(): bool { + return is_user_logged_in(); + } + /** - * GET /ai/status — AI 状态 + * 投稿/编辑写作辅助:已登录且可编辑文章或允许前台投稿。 */ + public function check_write_assist(): bool { + if (!is_user_logged_in()) { + return false; + } + if (current_user_can('edit_posts')) { + return true; + } + if (class_exists('AKSRT_Front_Submit')) { + return (bool) AKSRT_Front_Submit::get_instance()->can_submit(); + } + return false; + } + public function get_status(): WP_REST_Response { if (!class_exists('AKSRT_AI')) { return $this->success(['data' => [ @@ -65,22 +85,19 @@ class AKSRT_Admin_AI_Controller extends AKSRT_Admin_Controller_Base { 'provider' => 'none', 'configured' => false, 'available' => false, + 'backend' => 'none', ]]); } return $this->success(['data' => AKSRT_AI::get_instance()->get_status()]); } - /** - * POST /ai/settings — 更新 AI 设置 - */ public function update_settings(WP_REST_Request $request): WP_REST_Response { if (!class_exists('AKSRT_AI')) { - return $this->error('AI 模块未加载', 500); + return $this->error(__('AI 模块未加载', 'aksrt'), 500); } - $params = $this->get_json_params($request); - + $params = $this->get_json_params($request); $allowed = [ 'enabled' => 'bool', 'provider' => 'string', @@ -93,7 +110,9 @@ class AKSRT_Admin_AI_Controller extends AKSRT_Admin_Controller_Base { $config = []; foreach ($allowed as $key => $type) { - if (!isset($params[$key])) continue; + if (!isset($params[$key])) { + continue; + } switch ($type) { case 'bool': $config[$key] = !empty($params[$key]); @@ -110,7 +129,6 @@ class AKSRT_Admin_AI_Controller extends AKSRT_Admin_Controller_Base { } $success = AKSRT_AI::get_instance()->update_config($config); - if ($success) { return $this->success(['message' => __('AI 设置已保存', 'aksrt')]); } @@ -118,84 +136,119 @@ class AKSRT_Admin_AI_Controller extends AKSRT_Admin_Controller_Base { } /** - * POST /ai/seo-suggest — SEO 建议 + * POST /ai/seo-generate — 从 post/term 实体生成 SEO 三字段。 */ + public function seo_generate(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_AI_SEO')) { + return $this->error(__('AI SEO 模块未加载', 'aksrt'), 500); + } + + $params = $this->get_json_params($request); + $type = sanitize_key((string) ($params['type'] ?? 'post')); + $id = absint($params['id'] ?? 0); + + if (!in_array($type, ['post', 'term'], true) || $id <= 0) { + return $this->error(__('参数错误', 'aksrt'), 422); + } + + if ($type === 'post') { + if (!current_user_can('edit_post', $id)) { + return $this->error(__('权限不足,无法编辑此文章', 'aksrt'), 403); + } + } else { + if (!current_user_can('edit_term', $id)) { + return $this->error(__('权限不足,无法编辑此分类/标签', 'aksrt'), 403); + } + } + + $result = AKSRT_AI_SEO::get_instance()->generate_fields($type, $id); + if (empty($result['success'])) { + return $this->error((string) ($result['message'] ?? __('生成失败', 'aksrt'))); + } + + return $this->success(['data' => $result['data']]); + } + public function seo_suggest(WP_REST_Request $request): WP_REST_Response { if (!class_exists('AKSRT_AI_SEO')) { - return $this->error('AI SEO 模块未加载', 500); + return $this->error(__('AI SEO 模块未加载', 'aksrt'), 500); } $params = $this->get_json_params($request); $title = sanitize_text_field((string) ($params['title'] ?? '')); $content = wp_kses_post((string) ($params['content'] ?? '')); + $post_id = absint($params['post_id'] ?? 0); - if (empty($title)) { - return $this->error('标题不能为空'); + if ($post_id > 0 && !current_user_can('edit_post', $post_id)) { + return $this->error(__('权限不足', 'aksrt'), 403); + } + + if ($title === '') { + return $this->error(__('标题不能为空', 'aksrt')); } $result = AKSRT_AI_SEO::get_instance()->suggest($title, $content); - - if ($result['success']) { + if (!empty($result['success'])) { return $this->success(['data' => $result['data']]); } - return $this->error($result['message'] ?? '生成失败'); + return $this->error((string) ($result['message'] ?? __('生成失败', 'aksrt'))); } - /** - * POST /ai/summary-suggest — 摘要建议 - */ public function summary_suggest(WP_REST_Request $request): WP_REST_Response { if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) { - return $this->error('AI 模块未加载', 500); + return $this->error(__('AI 模块未加载', 'aksrt'), 500); } $params = $this->get_json_params($request); - $title = sanitize_text_field((string) ($params['title'] ?? '')); $content = wp_kses_post((string) ($params['content'] ?? '')); $max_len = max(50, min(500, (int) ($params['max_length'] ?? 200))); - if (empty($content)) { - return $this->error('内容不能为空'); + if (trim(wp_strip_all_tags($content)) === '') { + return $this->error(__('内容不能为空', 'aksrt')); } $prompt = AKSRT_AI_Prompt::get_instance()->build_summary_prompt($content, $max_len); - $result = AKSRT_AI::get_instance()->generate($prompt); + $result = AKSRT_AI::get_instance()->generate_text($prompt, [ + 'max_tokens' => min(600, $max_len * 2), + 'temperature' => 0.5, + ]); - if ($result['success']) { + if (!empty($result['success'])) { return $this->success(['data' => ['summary' => $result['content']]]); } - return $this->error($result['message'] ?? '生成失败'); + return $this->error((string) ($result['message'] ?? __('生成失败', 'aksrt'))); } - /** - * POST /ai/tags-suggest — 标签建议 - */ public function tags_suggest(WP_REST_Request $request): WP_REST_Response { if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) { - return $this->error('AI 模块未加载', 500); + return $this->error(__('AI 模块未加载', 'aksrt'), 500); } $params = $this->get_json_params($request); $title = sanitize_text_field((string) ($params['title'] ?? '')); $content = wp_kses_post((string) ($params['content'] ?? '')); - if (empty($title) && empty($content)) { - return $this->error('标题或内容不能为空'); + if ($title === '' && trim(wp_strip_all_tags($content)) === '') { + return $this->error(__('标题或内容不能为空', 'aksrt')); } $prompt = AKSRT_AI_Prompt::get_instance()->build_tags_prompt($title, $content); - $result = AKSRT_AI::get_instance()->generate($prompt); + $result = AKSRT_AI::get_instance()->generate_text($prompt, [ + 'max_tokens' => 300, + 'temperature' => 0.5, + ]); - if ($result['success']) { - $tags = json_decode($result['content'], true); + if (!empty($result['success'])) { + $tags = json_decode((string) $result['content'], true); if (!is_array($tags)) { - $tags = array_filter(array_map('trim', explode(',', $result['content']))); + $tags = array_filter(array_map('trim', explode(',', (string) $result['content']))); } + $tags = array_values(array_map('sanitize_text_field', $tags)); return $this->success(['data' => ['tags' => $tags]]); } - return $this->error($result['message'] ?? '生成失败'); + return $this->error((string) ($result['message'] ?? __('生成失败', 'aksrt'))); } } diff --git a/inc/classes/class-aksrt-ai-prompt.php b/inc/classes/class-aksrt-ai-prompt.php index af97c752..b6c46a87 100644 --- a/inc/classes/class-aksrt-ai-prompt.php +++ b/inc/classes/class-aksrt-ai-prompt.php @@ -2,8 +2,6 @@ /** * AKSRT AI Prompt — AI Prompt 模板管理 * - * 管理各种 AI 功能的 prompt 模板。 - * * @package AKSRT */ @@ -21,27 +19,71 @@ class AKSRT_AI_Prompt { } /** - * 构建 SEO 建议 prompt - * - * 优先读取后台 ai_seo_*_prompt 设置,为空则使用内置默认提示词。 + * SEO 标题 system instruction(Zibll 默认长提示词)。 */ - public function build_seo_prompt(string $title, string $content): string { - // 读取后台自定义提示词 - $custom_title = ''; - $custom_kw = ''; - $custom_desc = ''; - - if (class_exists('AKSRT_Settings')) { - $s = AKSRT_Settings::get_all(); - $custom_title = trim((string) ($s['ai_seo_title_prompt'] ?? '')); - $custom_kw = trim((string) ($s['ai_seo_keywords_prompt'] ?? '')); - $custom_desc = trim((string) ($s['ai_seo_description_prompt'] ?? '')); + public function get_seo_title_system_instruction(): string { + $custom = $this->read_custom_prompt('ai_seo_title_prompt'); + if ($custom !== '') { + return $custom; } - // 内置默认提示词(zibll 风格长提示词) - $title_instruction = $custom_title ?: '请为文章生成一个 SEO 友好的标题,要求:包含核心关键词,控制在 60 个字符以内,语言自然流畅,吸引用户点击。'; - $kw_instruction = $custom_kw ?: '请为文章提取 5-8 个 SEO 关键词,要求:涵盖核心主题词和长尾词,按搜索热度排序,用英文逗号分隔。'; - $desc_instruction = $custom_desc ?: '请为文章生成一段 SEO 描述(meta description),要求:160 个字符以内,准确概括文章内容,包含核心关键词,具有吸引力能促进点击。'; + return '你是一名资深的网站 SEO 编辑,擅长撰写既符合搜索习惯又具备点击力的文章标题。 +请严格遵循以下规则: +1. 输出 1 条最佳标题,作为该文章的 。 +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 优化建议。 @@ -60,9 +102,6 @@ class AKSRT_AI_Prompt { PROMPT; } - /** - * 构建内容摘要 prompt - */ public function build_summary_prompt(string $content, int $max_length = 200): string { $content = mb_substr($content, 0, 3000); return <<<PROMPT @@ -75,9 +114,6 @@ PROMPT; PROMPT; } - /** - * 构建标签建议 prompt - */ public function build_tags_prompt(string $title, string $content): string { $content = mb_substr($content, 0, 2000); return <<<PROMPT @@ -93,9 +129,6 @@ PROMPT; PROMPT; } - /** - * 构建摘要建议 prompt - */ public function build_excerpt_prompt(string $title, string $content): string { $content = mb_substr($content, 0, 2000); return <<<PROMPT @@ -114,4 +147,12 @@ PROMPT; 只返回摘要文本,不要其他内容。 PROMPT; } + + private function read_custom_prompt(string $key): string { + if (!class_exists('AKSRT_Settings')) { + return ''; + } + $s = AKSRT_Settings::get_all(); + return trim((string) ($s[$key] ?? '')); + } } diff --git a/inc/classes/class-aksrt-ai-seo.php b/inc/classes/class-aksrt-ai-seo.php index 272d0372..1915a2e1 100644 --- a/inc/classes/class-aksrt-ai-seo.php +++ b/inc/classes/class-aksrt-ai-seo.php @@ -1,8 +1,6 @@ <?php /** - * AKSRT AI SEO — AI SEO 辅助 - * - * 提供 SEO 标题、描述、关键词建议。 + * AKSRT AI SEO — SEO 字段生成(对齐 Zibll 闭环) * * @package AKSRT */ @@ -21,91 +19,263 @@ class AKSRT_AI_SEO { } /** - * 生成 SEO 建议 - * - * @param string $title 文章标题 - * @param string $content 文章内容(截取前2000字) - * @return array{success: bool, data?: array, message?: string} + * SEO AI 功能是否启用。 */ - public function suggest(string $title, string $content = ''): array { - // seo_ai_enabled 主开关 - if (class_exists('AKSRT_Settings')) { - $seo = AKSRT_Settings::get_seo_settings(); - if (empty($seo['seo_ai_enabled'])) { - return ['success' => false, 'message' => 'AI SEO 功能未启用']; - } + public function is_enabled(): bool { + if (!class_exists('AKSRT_Settings')) { + return false; + } + $seo = AKSRT_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')) { - return ['success' => false, 'message' => 'AI 模块未加载']; + if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) { + return [ + 'success' => false, + 'message' => __('AI 模块未加载', 'aksrt'), + ]; } $ai = AKSRT_AI::get_instance(); if (!$ai->is_available()) { - return ['success' => false, 'message' => 'AI 服务未启用']; + return [ + 'success' => false, + 'message' => __('AI 服务未启用或未配置', 'aksrt'), + ]; } - // 截取内容避免 token 超限 - $content = mb_substr($content, 0, 2000); - - $prompt = AKSRT_AI_Prompt::get_instance()->build_seo_prompt($title, $content); - - $result = $ai->generate($prompt, [ - 'max_tokens' => 500, - 'temperature' => 0.5, - ]); - - if (!$result['success']) { - return $result; + $type = $type === 'term' ? 'term' : 'post'; + if ($id <= 0) { + return [ + 'success' => false, + 'message' => __('请先保存后再生成 SEO', 'aksrt'), + ]; } - // 解析 JSON 响应 - $parsed = $this->parse_seo_response($result['content']); + $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 = AKSRT_AI_Prompt::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' => $parsed, + 'data' => [ + 'title' => $title, + 'keywords' => $keywords, + 'description' => $description, + ], ]; } /** - * 解析 SEO 响应 + * 草稿场景:用传入 title/content 生成(兼容旧 /ai/seo-suggest)。 */ - private function parse_seo_response(string $content): array { - $defaults = [ - 'suggested_title' => '', - 'suggested_description' => '', - 'suggested_keywords' => [], + public function suggest(string $title, string $content = ''): array { + if (!$this->is_enabled()) { + return ['success' => false, 'message' => __('AI SEO 功能未启用', 'aksrt')]; + } + + if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) { + return ['success' => false, 'message' => __('AI 模块未加载', 'aksrt')]; + } + + $ai = AKSRT_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 = AKSRT_AI_Prompt::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, + ], + ], ]; + } - // 尝试 JSON 解析 - $json = json_decode($content, true); - if (is_array($json)) { - return array_merge($defaults, $json); + /** + * @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')); } - // 尝试从文本中提取 - $lines = explode("\n", $content); - $result = $defaults; - $current_key = ''; + $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), ''); - foreach ($lines as $line) { - $line = trim($line); - if (empty($line)) continue; + if ($title === '' && $content === '' && $excerpt === '') { + return new WP_Error('empty_content', __('无可用内容,请先填写标题、摘要或正文并保存', 'aksrt')); + } - if (stripos($line, '标题') !== false || stripos($line, 'title') !== false) { - $current_key = 'suggested_title'; - $result[$current_key] = trim(preg_replace('/^[^::]+[::]\s*/', '', $line)); - } elseif (stripos($line, '描述') !== false || stripos($line, 'description') !== false) { - $current_key = 'suggested_description'; - $result[$current_key] = trim(preg_replace('/^[^::]+[::]\s*/', '', $line)); - } elseif (stripos($line, '关键词') !== false || stripos($line, 'keywords') !== false) { - $current_key = 'suggested_keywords'; - $keywords_str = trim(preg_replace('/^[^::]+[::]\s*/', '', $line)); - $result[$current_key] = array_map('trim', explode(',', $keywords_str)); + $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(AKSRT_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') ? AKSRT_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; } - return $result; + 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)); } } diff --git a/inc/classes/class-aksrt-ai.php b/inc/classes/class-aksrt-ai.php index cd9a523b..28ca977c 100644 --- a/inc/classes/class-aksrt-ai.php +++ b/inc/classes/class-aksrt-ai.php @@ -2,8 +2,9 @@ /** * AKSRT AI — AI 子系统核心 * - * 统一管理 AI provider 调用、速率限制和输出处理。 - * 默认关闭,需管理员显式启用。 + * 统一管理 AI 调用:优先 WordPress AI Client(Connectors), + * 回退主题 OpenAI 兼容配置。默认关闭,需管理员显式启用主题 provider + * 或配置核心 Connectors。 * * @package AKSRT */ @@ -25,7 +26,7 @@ class AKSRT_AI { } /** - * 获取 AI 配置 + * 获取 AI 配置(主题侧 provider) */ public function get_config(): array { $defaults = [ @@ -35,11 +36,13 @@ class AKSRT_AI { 'api_base' => 'https://api.openai.com/v1', 'model' => 'gpt-3.5-turbo', 'max_tokens' => 1000, - 'rate_limit' => 10, // 每分钟最大调用次数 + 'rate_limit' => 10, ]; $saved = get_option('aksrt_ai_settings', []); - if (!is_array($saved)) $saved = []; + if (!is_array($saved)) { + $saved = []; + } return array_merge($defaults, $saved); } @@ -49,9 +52,8 @@ class AKSRT_AI { */ public function update_config(array $config): bool { $current = $this->get_config(); - $merged = array_merge($current, $config); + $merged = array_merge($current, $config); - // 如果 api_key 为掩码,保留原值 if (isset($config['api_key']) && $config['api_key'] === '***') { $merged['api_key'] = $current['api_key']; } @@ -60,125 +62,258 @@ class AKSRT_AI { } /** - * 检查 AI 是否可用 + * WordPress 核心 AI Client 是否可用。 */ - public function is_available(): bool { + public function is_wp_ai_available(): bool { + if (!function_exists('wp_ai_client_prompt')) { + return false; + } + if (function_exists('wp_supports_ai') && !wp_supports_ai()) { + return false; + } + return true; + } + + /** + * 主题 OpenAI 兼容 provider 是否可用。 + */ + public function is_theme_provider_available(): bool { $config = $this->get_config(); return !empty($config['enabled']) - && $config['provider'] !== self::PROVIDER_NONE + && ($config['provider'] ?? self::PROVIDER_NONE) !== self::PROVIDER_NONE && !empty($config['api_key']); } /** - * 获取 AI 状态 + * 任一后端可用即可生成。 + */ + public function is_available(): bool { + return $this->is_wp_ai_available() || $this->is_theme_provider_available(); + } + + /** + * 当前优先后端标识。 + */ + public function resolve_backend(): string { + if ($this->is_wp_ai_available()) { + return 'wp_ai'; + } + if ($this->is_theme_provider_available()) { + return 'theme'; + } + return 'none'; + } + + /** + * 获取 AI 状态(不回显完整密钥)。 */ public function get_status(): array { $config = $this->get_config(); + $seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : []; return [ - 'enabled' => !empty($config['enabled']), - 'provider' => $config['provider'], - 'configured' => !empty($config['api_key']), - 'model' => $config['model'], - 'api_key' => !empty($config['api_key']) ? '***' : '', - 'rate_limit' => $config['rate_limit'], - 'available' => $this->is_available(), + '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(), ]; } /** - * 调用 AI 生成 + * 统一文本生成出口。 * - * @param string $prompt 提示词 - * @param array $options 选项 - * @return array{success: bool, content?: string, message?: string} + * @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'); + + // 1) Prefer WP AI Client + if ($this->is_wp_ai_available()) { + $wp_result = $this->generate_via_wp_ai($prompt, $system, $options); + if (!empty($wp_result['success'])) { + $this->record_call(); + $wp_result['backend'] = 'wp_ai'; + return $wp_result; + } + // Fall through to theme if configured + if (!$this->is_theme_provider_available()) { + $this->record_call(); + return $wp_result; + } + } + + // 2) Theme OpenAI-compatible + $theme_result = $this->generate_via_theme($prompt, $system, $options); + $this->record_call(); + if (!empty($theme_result['success'])) { + $theme_result['backend'] = 'theme'; + } + return $theme_result; + } + + /** + * 兼容旧调用。 */ public function generate(string $prompt, array $options = []): array { - if (!$this->is_available()) { - return ['success' => false, 'message' => 'AI 服务未启用或未配置']; - } + return $this->generate_text($prompt, $options); + } - // 速率限制检查 - if (!$this->check_rate_limit()) { - return ['success' => false, 'message' => '请求过于频繁,请稍后再试']; + 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'), + ]; } + } - $config = $this->get_config(); - $max_tokens = (int) ($options['max_tokens'] ?? $config['max_tokens']); + 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 = rtrim((string) $config['api_base'], '/'); - $response = wp_remote_post($config['api_base'] . '/chat/completions', [ + $response = wp_remote_post($api_base . '/chat/completions', [ 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $config['api_key'], ], - 'body' => wp_json_encode([ + 'body' => wp_json_encode([ 'model' => $config['model'], 'messages' => [ - ['role' => 'system', 'content' => '你是一个内容助手,提供建议性内容。输出仅作为参考建议,不会自动应用到线上内容。'], + ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $prompt], ], 'max_tokens' => $max_tokens, 'temperature' => $temperature, ]), - 'timeout' => 30, + 'timeout' => 45, ]); - // 记录调用 - $this->record_call(); - if (is_wp_error($response)) { - return ['success' => false, 'message' => 'API 请求失败: ' . $response->get_error_message()]; + return [ + 'success' => false, + 'message' => sprintf( + /* translators: %s: error message */ + __('API 请求失败: %s', 'aksrt'), + $response->get_error_message() + ), + ]; } - $body = json_decode(wp_remote_retrieve_body($response), true); - $status_code = wp_remote_retrieve_response_code($response); + $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 = $body['error']['message'] ?? '未知错误'; - return ['success' => false, 'message' => 'API 错误 (' . $status_code . '): ' . $error_msg]; + $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 = $body['choices'][0]['message'] ?? []; - $content = $message['content'] ?? ''; + $message = is_array($body) ? ($body['choices'][0]['message'] ?? []) : []; + $content = is_array($message) ? (string) ($message['content'] ?? '') : ''; - // 兼容推理模型(如 mimo-v2.5-pro):content 为空时取 reasoning_content - if (empty($content) && !empty($message['reasoning_content'])) { - $content = $message['reasoning_content']; + if ($content === '' && is_array($message) && !empty($message['reasoning_content'])) { + $content = (string) $message['reasoning_content']; } - if (empty($content)) { - return ['success' => false, 'message' => 'AI 返回内容为空(可能 max_tokens 不足)']; + $content = trim($content); + if ($content === '') { + return [ + 'success' => false, + 'message' => __('AI 返回内容为空(可能 max_tokens 不足)', 'aksrt'), + ]; } return [ - 'success' => true, - 'content' => trim($content), - 'reasoning' => $message['reasoning_content'] ?? '', - 'usage' => $body['usage'] ?? [], - 'finish_reason' => $body['choices'][0]['finish_reason'] ?? '', + '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; + $limit = (int) $config['rate_limit']; + if ($limit <= 0) { + return true; + } - $key = 'aksrt_ai_rate_' . get_current_user_id(); + $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(); + $key = 'aksrt_ai_rate_' . get_current_user_id(); $count = (int) get_transient($key); set_transient($key, $count + 1, MINUTE_IN_SECONDS); } diff --git a/inc/classes/class-aksrt-seo-head.php b/inc/classes/class-aksrt-seo-head.php index f98615f8..fa5c5c85 100644 --- a/inc/classes/class-aksrt-seo-head.php +++ b/inc/classes/class-aksrt-seo-head.php @@ -126,9 +126,20 @@ class AKSRT_SEO_Head { $term = get_queried_object(); if ($term instanceof WP_Term) { $payload['canonical'] = (string) get_term_link($term); - $term_desc = trim(wp_strip_all_tags((string) term_description($term))); - if ($term_desc !== '') { - $payload['description'] = $term_desc; + $meta = $this->term_seo_meta((int) $term->term_id); + if ($meta['title'] !== '') { + $payload['title'] = $meta['title']; + } + if ($meta['description'] !== '') { + $payload['description'] = $meta['description']; + } else { + $term_desc = trim(wp_strip_all_tags((string) term_description($term))); + if ($term_desc !== '') { + $payload['description'] = $term_desc; + } + } + if ($meta['keywords'] !== '') { + $payload['keywords'] = $meta['keywords']; } } } @@ -163,6 +174,23 @@ class AKSRT_SEO_Head { ]; } + /** + * 读取 term SEO 覆盖。 + * + * @return array{title:string,description:string,keywords:string} + */ + private function term_seo_meta(int $term_id): array { + $get = static function (string $key) use ($term_id): string { + return trim((string) get_term_meta($term_id, $key, true)); + }; + + return [ + 'title' => $get('_aksrt_seo_title'), + 'description' => $get('_aksrt_seo_description'), + 'keywords' => $get('_aksrt_seo_keywords'), + ]; + } + /** * 由分类/标签生成关键词回退。 */ diff --git a/inc/classes/class-aksrt-seo-meta-box.php b/inc/classes/class-aksrt-seo-meta-box.php new file mode 100644 index 00000000..9843cb65 --- /dev/null +++ b/inc/classes/class-aksrt-seo-meta-box.php @@ -0,0 +1,245 @@ +<?php +/** + * AKSRT SEO Meta Box — 文章/页面独立 SEO + AI 生成按钮 + * + * Meta keys 与 AKSRT_SEO_Head 读取一致: + * _aksrt_seo_title / _aksrt_seo_keywords / _aksrt_seo_description + * + * @package AKSRT + */ + +defined('ABSPATH') || exit; + +class AKSRT_SEO_Meta_Box { + + private static $instance = null; + + public const META_TITLE = '_aksrt_seo_title'; + public const META_KEYWORDS = '_aksrt_seo_keywords'; + public const META_DESCRIPTION = '_aksrt_seo_description'; + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } + + public function init(): void { + add_action('add_meta_boxes', [$this, 'register_meta_box']); + add_action('save_post', [$this, 'save_meta_box'], 10, 2); + add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']); + + // Term SEO (category / post_tag) + add_action('category_edit_form_fields', [$this, 'render_term_fields'], 20); + add_action('post_tag_edit_form_fields', [$this, 'render_term_fields'], 20); + add_action('edited_category', [$this, 'save_term_fields']); + add_action('edited_post_tag', [$this, 'save_term_fields']); + } + + public function register_meta_box(): void { + foreach (['post', 'page'] as $screen) { + add_meta_box( + 'aksrt_seo_meta', + __('独立 SEO', 'aksrt'), + [$this, 'render_meta_box'], + $screen, + 'normal', + 'high' + ); + } + } + + public function render_meta_box(WP_Post $post): void { + wp_nonce_field('aksrt_seo_meta_save', 'aksrt_seo_meta_nonce'); + + $title = (string) get_post_meta($post->ID, self::META_TITLE, true); + $keywords = (string) get_post_meta($post->ID, self::META_KEYWORDS, true); + $description = (string) get_post_meta($post->ID, self::META_DESCRIPTION, true); + + $ai_btn = $this->render_ai_button('post', (int) $post->ID); + ?> + <div class="aksrt-seo-meta-box"> + <p> + <label for="aksrt_seo_title"><strong><?php esc_html_e('SEO 标题', 'aksrt'); ?></strong></label> + <input type="text" class="widefat" id="aksrt_seo_title" name="aksrt_seo_title" value="<?php echo esc_attr($title); ?>" /> + </p> + <p> + <label for="aksrt_seo_keywords"><strong><?php esc_html_e('SEO 关键词', 'aksrt'); ?></strong></label> + <input type="text" class="widefat" id="aksrt_seo_keywords" name="aksrt_seo_keywords" value="<?php echo esc_attr($keywords); ?>" placeholder="<?php esc_attr_e('多个关键词用英文逗号分隔', 'aksrt'); ?>" /> + </p> + <p> + <label for="aksrt_seo_description"><strong><?php esc_html_e('SEO 描述', 'aksrt'); ?></strong></label> + <textarea class="widefat" rows="3" id="aksrt_seo_description" name="aksrt_seo_description"><?php echo esc_textarea($description); ?></textarea> + <span class="description"><?php esc_html_e('建议 50–150 字。', 'aksrt'); ?><?php echo $ai_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> + </p> + </div> + <?php + } + + public function save_meta_box(int $post_id, $post = null): void { + if (!isset($_POST['aksrt_seo_meta_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['aksrt_seo_meta_nonce'])), 'aksrt_seo_meta_save')) { + return; + } + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { + return; + } + if (!current_user_can('edit_post', $post_id)) { + return; + } + if (wp_is_post_revision($post_id)) { + return; + } + + $map = [ + self::META_TITLE => 'aksrt_seo_title', + self::META_KEYWORDS => 'aksrt_seo_keywords', + self::META_DESCRIPTION => 'aksrt_seo_description', + ]; + + foreach ($map as $meta_key => $field) { + if (!isset($_POST[$field])) { + continue; + } + $value = sanitize_text_field(wp_unslash((string) $_POST[$field])); + if ($value === '') { + delete_post_meta($post_id, $meta_key); + } else { + update_post_meta($post_id, $meta_key, $value); + } + } + } + + /** + * @param WP_Term $term + */ + public function render_term_fields($term): void { + if (!$term instanceof WP_Term) { + return; + } + $title = (string) get_term_meta($term->term_id, self::META_TITLE, true); + $keywords = (string) get_term_meta($term->term_id, self::META_KEYWORDS, true); + $description = (string) get_term_meta($term->term_id, self::META_DESCRIPTION, true); + $ai_btn = $this->render_ai_button('term', (int) $term->term_id); + ?> + <tr class="form-field"> + <th scope="row"><label for="aksrt_seo_title"><?php esc_html_e('SEO 标题', 'aksrt'); ?></label></th> + <td> + <input type="text" name="aksrt_seo_title" id="aksrt_seo_title" class="aksrt-term-seo-title" value="<?php echo esc_attr($title); ?>" /> + </td> + </tr> + <tr class="form-field"> + <th scope="row"><label for="aksrt_seo_keywords"><?php esc_html_e('SEO 关键词', 'aksrt'); ?></label></th> + <td> + <input type="text" name="aksrt_seo_keywords" id="aksrt_seo_keywords" class="aksrt-term-seo-keywords" value="<?php echo esc_attr($keywords); ?>" /> + </td> + </tr> + <tr class="form-field"> + <th scope="row"><label for="aksrt_seo_description"><?php esc_html_e('SEO 描述', 'aksrt'); ?></label></th> + <td> + <textarea name="aksrt_seo_description" id="aksrt_seo_description" class="aksrt-term-seo-description" rows="4"><?php echo esc_textarea($description); ?></textarea> + <p class="description"><?php esc_html_e('主题默认会自动设置 SEO 内容,如需单独设置请在此输入。', 'aksrt'); ?><?php echo $ai_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></p> + </td> + </tr> + <?php + } + + public function save_term_fields(int $term_id): void { + if (!current_user_can('edit_term', $term_id)) { + return; + } + + $map = [ + self::META_TITLE => 'aksrt_seo_title', + self::META_KEYWORDS => 'aksrt_seo_keywords', + self::META_DESCRIPTION => 'aksrt_seo_description', + ]; + + foreach ($map as $meta_key => $field) { + if (!isset($_POST[$field])) { + continue; + } + $value = sanitize_text_field(wp_unslash((string) $_POST[$field])); + if ($value === '') { + delete_term_meta($term_id, $meta_key); + } else { + update_term_meta($term_id, $meta_key, $value); + } + } + } + + public function enqueue_assets(string $hook): void { + $screen = function_exists('get_current_screen') ? get_current_screen() : null; + if (!$screen) { + return; + } + $ok = in_array($screen->base, ['post', 'term', 'edit-tags'], true); + if (!$ok) { + return; + } + + $path = AKSRT_DIR . '/assets/js/admin-ai-seo.js'; + if (!is_file($path)) { + return; + } + + wp_enqueue_script( + 'aksrt-admin-ai-seo', + AKSRT_URI . '/assets/js/admin-ai-seo.js', + [], + (string) filemtime($path), + true + ); + + $status = class_exists('AKSRT_AI') ? AKSRT_AI::get_instance()->get_status() : []; + $seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : []; + + wp_localize_script('aksrt-admin-ai-seo', 'aksrtAiSeo', [ + 'restUrl' => esc_url_raw(rest_url('aksrt/v1')), + 'nonce' => wp_create_nonce('wp_rest'), + 'ai' => [ + 'available' => !empty($status['available']), + 'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']), + ], + 'i18n' => [ + 'generating' => __('生成中…', 'aksrt'), + 'generate' => __('AI 生成 SEO', 'aksrt'), + 'success' => __('已填入 SEO 字段,请检查后更新保存', 'aksrt'), + 'failed' => __('生成失败', 'aksrt'), + 'needSave' => __('请先保存后再生成 SEO', 'aksrt'), + 'notAvailable' => __('AI 未配置或未启用', 'aksrt'), + ], + ]); + + wp_add_inline_style('wp-admin', ' + .aksrt-ai-seo-button{display:inline-block;margin-left:8px;vertical-align:middle} + .aksrt-ai-seo-generate{cursor:pointer} + .aksrt-ai-seo-generate.is-loading{opacity:.6;pointer-events:none} + .aksrt-ai-seo-msg{display:block;margin-top:6px;font-size:12px} + .aksrt-ai-seo-msg.is-error{color:#b32d2e} + .aksrt-ai-seo-msg.is-ok{color:#007017} + '); + } + + private function render_ai_button(string $type, int $id): string { + $seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : []; + if (empty($seo['seo_enabled']) || empty($seo['seo_ai_enabled'])) { + return ''; + } + + $available = class_exists('AKSRT_AI') && AKSRT_AI::get_instance()->is_available(); + $disabled = (!$available || $id <= 0) ? ' disabled' : ''; + $title = !$available + ? esc_attr__('请先配置 AI 连接(扩展 → AI 辅助 或 WP Connectors)', 'aksrt') + : ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : ''); + + return sprintf( + '<span class="aksrt-ai-seo-button"><button type="button" class="button aksrt-ai-seo-generate"%s data-type="%s" data-id="%d" title="%s">%s</button></span>', + $disabled, + esc_attr($type), + $id, + $title, + esc_html__('AI 生成 SEO', 'aksrt') + ); + } +} diff --git a/templates/pages/page-submit.php b/templates/pages/page-submit.php index 3b2fcdbf..d5c1c120 100644 --- a/templates/pages/page-submit.php +++ b/templates/pages/page-submit.php @@ -127,7 +127,10 @@ $current_thumb = $edit_post ? get_the_post_thumbnail_url($edit_post->ID, 'larg <?php if (!empty($submit_settings['allow_excerpt'])) : ?> <div class="aksrt-form-group"> - <label for="submit-excerpt"><?php esc_html_e('摘要', 'aksrt'); ?></label> + <label for="submit-excerpt"> + <?php esc_html_e('摘要', 'aksrt'); ?> + <button type="button" class="aksrt-ai-assist-btn" data-ai-action="summary" hidden><?php esc_html_e('AI 生成摘要', 'aksrt'); ?></button> + </label> <textarea id="submit-excerpt" name="excerpt" class="aksrt-form-input" rows="3" maxlength="300" placeholder="<?php esc_attr_e('可选:文章摘要', 'aksrt'); ?>"><?php echo esc_textarea($current_excerpt); ?></textarea> </div> <?php endif; ?> @@ -147,7 +150,10 @@ $current_thumb = $edit_post ? get_the_post_thumbnail_url($edit_post->ID, 'larg <?php if (!empty($submit_settings['allow_tags'])) : ?> <div class="aksrt-form-group aksrt-submit-form__col"> - <label for="submit-tags"><?php esc_html_e('标签', 'aksrt'); ?></label> + <label for="submit-tags"> + <?php esc_html_e('标签', 'aksrt'); ?> + <button type="button" class="aksrt-ai-assist-btn" data-ai-action="tags" hidden><?php esc_html_e('AI 建议标签', 'aksrt'); ?></button> + </label> <input type="text" id="submit-tags" name="tags" class="aksrt-form-input" placeholder="<?php esc_attr_e('多个标签用逗号分隔', 'aksrt'); ?>" value="<?php echo esc_attr($current_tags); ?>"> </div> <?php endif; ?> @@ -274,10 +280,18 @@ if (file_exists($front_submit_path)) { (string) filemtime($front_submit_path), true ); + $ai_available = class_exists('AKSRT_AI') && AKSRT_AI::get_instance()->is_available(); wp_localize_script('aksrt-front-submit', 'aksrtSubmitData', [ - 'ajaxUrl' => admin_url('admin-ajax.php'), - 'nonce' => wp_create_nonce('aksrt_submit_nonce'), - 'i18n' => [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('aksrt_submit_nonce'), + 'restUrl' => esc_url_raw(rest_url('aksrt/v1')), + 'restNonce' => wp_create_nonce('wp_rest'), + 'ai' => [ + 'available' => $ai_available, + 'summary' => $ai_available, + 'tags' => $ai_available, + ], + 'i18n' => [ 'confirmLeave' => __('你有未保存的更改,确定要离开吗?', 'aksrt'), 'saving' => __('保存中...', 'aksrt'), 'saved' => __('草稿已保存', 'aksrt'), @@ -285,6 +299,10 @@ if (file_exists($front_submit_path)) { 'submitted' => __('投稿已提交', 'aksrt'), 'error' => __('操作失败,请重试', 'aksrt'), 'confirmDelete' => __('确定要删除这个草稿吗?', 'aksrt'), + 'aiGenerating' => __('生成中…', 'aksrt'), + 'aiFailed' => __('AI 生成失败', 'aksrt'), + 'needContent' => __('请先填写正文内容', 'aksrt'), + 'aiFilled' => __('已填入,可继续编辑', 'aksrt'), ], ]); }