Merge pull request 'feat(ai): 打通 SEO 闭环与投稿写作辅助' (#87) from worktree-fix-ai-status-403 into main
Reviewed-on: CYL/wordpress-theme-aksrt#87
This commit is contained in:
@@ -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 }) },
|
||||
}
|
||||
|
||||
@@ -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 框,摘要/标签在前台投稿页。',
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,65 +1,63 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="AI 辅助(兼容)" desc="兼容保留的 AI 内容辅助能力,包括 SEO 建议、摘要生成、标签建议等。" />
|
||||
<PageHeader title="AI 连接配置" desc="配置 AI 生成能力的连接方式。优先使用 WordPress AI Connectors;也可填写 OpenAI 兼容接口。SEO 生成入口在文章「独立 SEO」框,摘要/标签入口在前台投稿页。" />
|
||||
|
||||
<div v-if="loadError" class="aksrt-ai__form-card aksrt-ai__load-error" role="alert">
|
||||
<strong>加载失败:</strong>{{ loadError }}
|
||||
</div>
|
||||
|
||||
<!-- 状态卡片 -->
|
||||
<div class="aksrt-ai__status">
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.enabled ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
|
||||
<i :class="status.enabled ? 'fas fa-robot' : 'fas fa-robot'"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">AI 状态</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.enabled ? '已启用' : '已关闭' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.configured ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
||||
<i class="fas fa-key"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">API Key</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
|
||||
<i class="fas fa-brain"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">模型</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.model || '未设置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
|
||||
<i class="fas fa-robot"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">可用状态</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.wp_ai_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
||||
<i class="fas fa-plug"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">WP AI Client</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.wp_ai_available ? '已就绪' : '不可用' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon" :class="status.theme_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
||||
<i class="fas fa-key"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">主题 Provider</div>
|
||||
<div class="aksrt-ai__status-value">{{ status.theme_available ? '已配置' : '未配置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-card">
|
||||
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
|
||||
<i class="fas fa-route"></i>
|
||||
</div>
|
||||
<div class="aksrt-ai__status-info">
|
||||
<div class="aksrt-ai__status-label">当前后端</div>
|
||||
<div class="aksrt-ai__status-value">{{ backendLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="aksrt-ai__form-card">
|
||||
<h3>基本配置</h3>
|
||||
<h3>主题 Provider 配置(回退链路)</h3>
|
||||
<p class="aksrt-form-hint">当站点已配置 WordPress AI Connectors 时,生成会优先走核心客户端;以下配置作为兼容回退。</p>
|
||||
<div class="aksrt-form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.enabled"> 启用 AI 内容辅助
|
||||
<input type="checkbox" v-model="form.enabled"> 启用主题 AI Provider
|
||||
</label>
|
||||
<p class="aksrt-form-hint">开启后可在投稿、编辑等场景使用 AI 辅助功能</p>
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>AI Provider</label>
|
||||
<select v-model="form.provider" class="aksrt-select">
|
||||
<option value="none">不使用</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai">OpenAI 兼容接口</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
@@ -70,7 +68,6 @@
|
||||
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
|
||||
<label>API Base URL</label>
|
||||
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://api.openai.com/v1">
|
||||
<p class="aksrt-form-hint">可替换为兼容 OpenAI 接口的第三方服务</p>
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>模型</label>
|
||||
@@ -91,49 +88,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能说明 -->
|
||||
<div class="aksrt-ai__form-card">
|
||||
<h3>支持的 AI 功能</h3>
|
||||
<h3>已接通能力</h3>
|
||||
<table class="aksrt-table aksrt-table--sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>功能</th>
|
||||
<th>说明</th>
|
||||
<th>使用场景</th>
|
||||
<th>能力</th>
|
||||
<th>入口</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="feat in features" :key="feat.key">
|
||||
<td>{{ feat.name }}</td>
|
||||
<td>{{ feat.desc }}</td>
|
||||
<td>{{ feat.scene }}</td>
|
||||
<td>
|
||||
<span class="aksrt-badge" :class="status.available ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
|
||||
{{ status.available ? '可用' : '未启用' }}
|
||||
<span class="aksrt-badge" :class="feat.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
|
||||
{{ feat.ready ? '可用' : '未就绪' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="aksrt-form-hint">SEO 开关与提示词请到「全局 → SEO」配置(seo_ai_enabled / ai_seo_*_prompt)。</p>
|
||||
</div>
|
||||
|
||||
<!-- 重要提示 -->
|
||||
<div class="aksrt-ai__form-card aksrt-ai__notice">
|
||||
<h3><i class="fas fa-exclamation-triangle"></i> 重要说明</h3>
|
||||
<ul>
|
||||
<li>AI 生成的内容仅作为<strong>建议参考</strong>,不会自动应用到线上内容</li>
|
||||
<li>所有 AI 输出需要<strong>人工审核确认</strong>后才能发布</li>
|
||||
<li>AI 默认关闭,需管理员显式启用并配置 API Key</li>
|
||||
<li>API Key 不会回显到前端,不会通过 <code>wp_localize_script</code> 透出</li>
|
||||
<li>内置速率限制,防止滥用</li>
|
||||
<li>AI 输出仅作为<strong>建议</strong>,不会自动写库;需用户点「更新/提交」才保存</li>
|
||||
<li>优先使用 WordPress AI Connectors;主题 API Key 为兼容回退</li>
|
||||
<li>生成接口要求登录与编辑/投稿权限;配置接口仍需管理员</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import api from '@/api';
|
||||
|
||||
@@ -149,6 +141,11 @@ export default {
|
||||
api_key: '',
|
||||
rate_limit: 10,
|
||||
available: false,
|
||||
backend: 'none',
|
||||
wp_ai_available: false,
|
||||
theme_available: false,
|
||||
seo_ai_enabled: false,
|
||||
write_assist: false,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
@@ -164,12 +161,20 @@ export default {
|
||||
const saving = ref(false);
|
||||
const loadError = ref('');
|
||||
|
||||
const features = [
|
||||
{ key: 'seo', name: 'SEO 建议', desc: '生成标题、描述、关键词建议', scene: '文章编辑' },
|
||||
{ key: 'summary', name: '摘要生成', desc: '自动生成文章摘要', scene: '文章编辑、投稿' },
|
||||
{ key: 'tags', name: '标签建议', desc: '根据内容推荐标签', scene: '文章编辑、投稿' },
|
||||
{ key: 'excerpt', name: '摘要建议', desc: '生成吸引人的列表摘要', scene: '文章编辑' },
|
||||
];
|
||||
const backendLabel = computed(() => {
|
||||
const map = { wp_ai: 'WP AI Client', theme: '主题 Provider', none: '无' };
|
||||
return map[status.value.backend] || status.value.backend || '无';
|
||||
});
|
||||
|
||||
const features = computed(() => {
|
||||
const avail = !!status.value.available;
|
||||
const seoReady = avail && !!status.value.seo_ai_enabled;
|
||||
return [
|
||||
{ key: 'seo', name: 'AI 生成 SEO', scene: '文章/分类「独立 SEO」框', ready: seoReady },
|
||||
{ key: 'summary', name: 'AI 生成摘要', scene: '前台投稿页', ready: avail },
|
||||
{ key: 'tags', name: 'AI 建议标签', scene: '前台投稿页', ready: avail },
|
||||
];
|
||||
});
|
||||
|
||||
function hydrateFromStatus(data) {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
@@ -181,18 +186,20 @@ export default {
|
||||
api_key: '',
|
||||
rate_limit: data.rate_limit || 10,
|
||||
available: !!data.available,
|
||||
backend: data.backend || 'none',
|
||||
wp_ai_available: !!data.wp_ai_available,
|
||||
theme_available: !!data.theme_available,
|
||||
seo_ai_enabled: !!data.seo_ai_enabled,
|
||||
write_assist: !!data.write_assist,
|
||||
};
|
||||
form.enabled = !!data.enabled;
|
||||
form.provider = data.provider || 'none';
|
||||
form.model = data.model || 'gpt-3.5-turbo';
|
||||
form.rate_limit = data.rate_limit || 10;
|
||||
if (data.api_base) {
|
||||
form.api_base = data.api_base;
|
||||
}
|
||||
if (data.api_base) form.api_base = data.api_base;
|
||||
if (typeof data.max_tokens === 'number' && data.max_tokens > 0) {
|
||||
form.max_tokens = data.max_tokens;
|
||||
}
|
||||
// 不回显 api_key
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
@@ -210,7 +217,6 @@ export default {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
// 如果密钥为空且已配置,发送掩码(后端应忽略 *** 保留原密钥)
|
||||
if (!payload.api_key && status.value.configured) {
|
||||
payload.api_key = '***';
|
||||
}
|
||||
@@ -227,7 +233,7 @@ export default {
|
||||
|
||||
onMounted(() => loadStatus());
|
||||
|
||||
return { status, form, saving, loadError, features, saveSettings };
|
||||
return { status, form, saving, loadError, features, backendLabel, saveSettings };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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') {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 条最佳标题,作为该文章的 <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 优化建议。
|
||||
@@ -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] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+199
-64
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 由分类/标签生成关键词回退。
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user