refactor(admin): drop AI connection config page for WP AI Connectors
Remove the "AI 连接配置" page under 扩展&增强 together with the theme's own OpenAI-compatible provider path. AI generation (SEO, summary, tags) now runs solely on the WordPress AI Client (wp_ai_client_prompt), with connections configured through WordPress AI Connectors. - AI.php keeps only the WP AI Client path: no provider config storage, no theme fallback, no rate limiting - Drop /ai/status, /ai/settings and legacy /ai/seo-suggest routes plus their handlers - Remove dead compat code: AISEO::suggest, AIPrompt::build_seo_prompt and the front-submit AI assist flow (its buttons were never rendered and its endpoints were never registered) - Update SEO schema/meta-box guidance that pointed at the deleted page - Add ai-config-removal contract test and refresh the toast and user-features contracts affected by the cleanup
This commit is contained in:
@@ -5,11 +5,4 @@ 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 辅助 */
|
||||
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 }) },
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
||||
import { renderFaIcon, schemaPage } from './helpers'
|
||||
|
||||
export const extensionsSection = {
|
||||
key: 'extensions',
|
||||
@@ -56,15 +56,5 @@ export const extensionsSection = {
|
||||
canonicalDomain: 'extensions',
|
||||
section: 'security',
|
||||
}),
|
||||
customPage({
|
||||
key: 'extensions-ai',
|
||||
name: 'ExtensionAI',
|
||||
label: 'AI 连接配置',
|
||||
route: '/extensions/ai',
|
||||
component: 'ExtensionAI',
|
||||
status: 'available',
|
||||
title: 'AI 连接配置',
|
||||
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<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.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
|
||||
<i class="fa fab fa-android"></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="fa 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="fa 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="fa fas fa-road"></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>主题 Provider 配置(回退链路)</h3>
|
||||
<p class="aksrt-form-hint">已配置主题 Provider 时优先走下方接口;未配置时才尝试 WordPress AI Connectors。NewAPI/兼容网关请填到 <code>/v1</code>(也可只填域名,保存时会自动补全)。</p>
|
||||
<div class="aksrt-form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.enabled"> 启用主题 AI Provider
|
||||
</label>
|
||||
</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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>API Key</label>
|
||||
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
|
||||
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
|
||||
</div>
|
||||
<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://llm.example.com/v1">
|
||||
<p class="aksrt-form-hint">示例:https://api.openai.com/v1 或 https://你的网关/v1(不要填到 /chat/completions)</p>
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>模型</label>
|
||||
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>最大 Token 数</label>
|
||||
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
|
||||
</div>
|
||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
||||
<label>每分钟请求限制</label>
|
||||
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
|
||||
</div>
|
||||
<div class="aksrt-form-actions">
|
||||
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-ai__form-card">
|
||||
<h3>已接通能力</h3>
|
||||
<table class="aksrt-table aksrt-table--sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>能力</th>
|
||||
<th>入口</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="feat in features" :key="feat.key">
|
||||
<td>{{ feat.name }}</td>
|
||||
<td>{{ feat.scene }}</td>
|
||||
<td>
|
||||
<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="fa fas fa-exclamation-triangle"></i> 重要说明</h3>
|
||||
<ul>
|
||||
<li>AI 输出仅作为<strong>建议</strong>,不会自动写库;需用户点「更新/提交」才保存</li>
|
||||
<li>主题 API Key 已配置时优先使用;否则回退 WordPress AI Connectors</li>
|
||||
<li>生成接口要求登录与编辑/投稿权限;配置接口仍需管理员</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import api from '@/api';
|
||||
|
||||
export default {
|
||||
name: 'ExtensionAI',
|
||||
components: { PageHeader },
|
||||
setup() {
|
||||
const status = ref({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
configured: false,
|
||||
model: '',
|
||||
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({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
api_key: '',
|
||||
api_base: 'https://api.openai.com/v1',
|
||||
model: 'gpt-3.5-turbo',
|
||||
max_tokens: 1000,
|
||||
rate_limit: 10,
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
const loadError = ref('');
|
||||
|
||||
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;
|
||||
status.value = {
|
||||
enabled: !!data.enabled,
|
||||
provider: data.provider || 'none',
|
||||
configured: !!data.configured,
|
||||
model: data.model || '',
|
||||
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 (typeof data.max_tokens === 'number' && data.max_tokens > 0) {
|
||||
form.max_tokens = data.max_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
loadError.value = '';
|
||||
try {
|
||||
const json = await api.getAiStatus();
|
||||
hydrateFromStatus(json.data || {});
|
||||
} catch (e) {
|
||||
console.error('Load AI status failed:', e);
|
||||
loadError.value = e?.message || '加载 AI 状态失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
enabled: form.enabled,
|
||||
provider: form.provider,
|
||||
api_base: form.api_base,
|
||||
model: form.model,
|
||||
max_tokens: form.max_tokens,
|
||||
rate_limit: form.rate_limit,
|
||||
};
|
||||
// Only send api_key when the user entered a new one.
|
||||
if (form.api_key && form.api_key !== '***') {
|
||||
payload.api_key = form.api_key;
|
||||
}
|
||||
const json = await api.updateAiSettings(payload);
|
||||
alert(json.message || '设置已保存');
|
||||
await loadStatus();
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
alert(e?.message || '保存失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadStatus());
|
||||
|
||||
return { status, form, saving, loadError, features, backendLabel, saveSettings };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-ai__status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.aksrt-ai__status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
.aksrt-ai__status-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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: var(--aksrt-color-text); }
|
||||
.aksrt-ai__status-label { font-size: 12px; color: #888; }
|
||||
.aksrt-ai__form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
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__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 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>
|
||||
@@ -1,3 +0,0 @@
|
||||
export const extensionsPageLoaders = {
|
||||
ExtensionAI: () => import('@/pages/extensions/ExtensionAI.vue'),
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { corePageLoaders } from './core'
|
||||
import { usersPageLoaders } from './users'
|
||||
import { toolsPageLoaders } from './tools'
|
||||
import { extensionsPageLoaders } from './extensions'
|
||||
|
||||
export const componentLoaders = {
|
||||
...corePageLoaders,
|
||||
...usersPageLoaders,
|
||||
...toolsPageLoaders,
|
||||
...extensionsPageLoaders,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export const seoSchema = {
|
||||
key: 'global.seo',
|
||||
title: 'SEO 优化',
|
||||
icon: 'fa fas fa-search',
|
||||
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。',
|
||||
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框,连接由 WordPress AI Connectors 提供。',
|
||||
defaults: {
|
||||
seo_enabled: true,
|
||||
seo_ai_enabled: false,
|
||||
@@ -48,7 +48,7 @@ export const seoSchema = {
|
||||
label: '启用 AI SEO 生成',
|
||||
type: 'switch',
|
||||
default: false,
|
||||
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。',
|
||||
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先在 WordPress 配置 AI Connectors。',
|
||||
dependsOn: { key: 'seo_enabled', value: true },
|
||||
},
|
||||
{ key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' },
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
this.bindFeatured();
|
||||
this.bindPay();
|
||||
this.bindLeaveWarning();
|
||||
this.bindAi();
|
||||
this.bindTagActive();
|
||||
this.loadFeaturedFromDom();
|
||||
},
|
||||
@@ -635,67 +634,6 @@
|
||||
});
|
||||
},
|
||||
|
||||
bindAi: function () {
|
||||
var ai = cfg.ai || {};
|
||||
if (!ai.available) {
|
||||
return;
|
||||
}
|
||||
qsa('.aksrt-ai-assist-btn').forEach(function (btn) {
|
||||
btn.hidden = false;
|
||||
btn.addEventListener('click', function () {
|
||||
var action = btn.getAttribute('data-ai-action');
|
||||
var content = getEditorContent();
|
||||
if (!content || content.replace(/<[^>]+>/g, '').trim().length < 10) {
|
||||
notyf(i18n.needContent || '请先填写正文内容', 'warning');
|
||||
return;
|
||||
}
|
||||
var toastId = action === 'tags'
|
||||
? 'aksrt-front-submit-ai-tags-toast'
|
||||
: 'aksrt-front-submit-ai-summary-toast';
|
||||
var old = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = i18n.aiGenerating || '...';
|
||||
notyf(i18n.aiGenerating || '正在生成…', 'load', '', toastId);
|
||||
fetch((cfg.restUrl || '').replace(/\/$/, '') + '/ai/' + action, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': cfg.restNonce || '',
|
||||
},
|
||||
body: JSON.stringify({ content: content }),
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (res) {
|
||||
var text = (res && (res.data || res).text) || (res && res.result) || '';
|
||||
if (!text) {
|
||||
throw new Error(i18n.aiFailed || 'AI failed');
|
||||
}
|
||||
if (action === 'summary') {
|
||||
var ex = qs('[name="excerpt"]');
|
||||
if (ex) {
|
||||
ex.value = text;
|
||||
}
|
||||
} else if (action === 'tags') {
|
||||
var tags = qs('[name="tags"]');
|
||||
if (tags) {
|
||||
tags.value = text;
|
||||
}
|
||||
}
|
||||
notyf(i18n.aiFilled || '已填入,可继续编辑', 'success', 5, toastId);
|
||||
})
|
||||
.catch(function (err) {
|
||||
notyf(err.message || i18n.aiFailed || 'AI 生成失败', 'danger', 5, toastId);
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
jQuery(document).off('auto_fun.aksrt-front-submit').on('auto_fun.aksrt-front-submit', function () { App.init(); });
|
||||
|
||||
+1
-105
@@ -19,30 +19,12 @@ class AIController extends ControllerBase {
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route(self::API_NAMESPACE, '/ai/status', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_status'],
|
||||
'permission_callback' => [$this, 'check_logged_in'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/ai/settings', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_settings'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
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_write_assist'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/ai/summary-suggest', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'summary_suggest'],
|
||||
@@ -76,66 +58,6 @@ class AIController extends ControllerBase {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function get_status(): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Ai\AI')) {
|
||||
return $this->success(['data' => [
|
||||
'enabled' => false,
|
||||
'provider' => 'none',
|
||||
'configured' => false,
|
||||
'available' => false,
|
||||
'backend' => 'none',
|
||||
]]);
|
||||
}
|
||||
|
||||
return $this->success(['data' => \AKSRT\Ai\AI::get_instance()->get_status()]);
|
||||
}
|
||||
|
||||
public function update_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Ai\AI')) {
|
||||
return $this->error(__('AI 模块未加载', 'aksrt'), 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$allowed = [
|
||||
'enabled' => 'bool',
|
||||
'provider' => 'string',
|
||||
'api_key' => 'string',
|
||||
'api_base' => 'url',
|
||||
'model' => 'string',
|
||||
'max_tokens' => 'int',
|
||||
'rate_limit' => 'int',
|
||||
];
|
||||
|
||||
$config = [];
|
||||
foreach ($allowed as $key => $type) {
|
||||
if (!isset($params[$key])) {
|
||||
continue;
|
||||
}
|
||||
switch ($type) {
|
||||
case 'bool':
|
||||
$config[$key] = !empty($params[$key]);
|
||||
break;
|
||||
case 'int':
|
||||
$config[$key] = (int) $params[$key];
|
||||
break;
|
||||
case 'url':
|
||||
$config[$key] = esc_url_raw((string) $params[$key]);
|
||||
break;
|
||||
default:
|
||||
$config[$key] = sanitize_text_field((string) $params[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$success = \AKSRT\Ai\AI::get_instance()->update_config($config);
|
||||
if ($success) {
|
||||
return $this->success([
|
||||
'message' => __('AI 设置已保存', 'aksrt'),
|
||||
'data' => \AKSRT\Ai\AI::get_instance()->get_status(),
|
||||
]);
|
||||
}
|
||||
return $this->error(__('保存失败', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ai/seo-generate — 从 post/term 实体生成 SEO 三字段。
|
||||
*/
|
||||
@@ -170,32 +92,6 @@ class AIController extends ControllerBase {
|
||||
return $this->success(['data' => $result['data']]);
|
||||
}
|
||||
|
||||
public function seo_suggest(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Ai\AISEO')) {
|
||||
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 ($post_id > 0 && !current_user_can('edit_post', $post_id)) {
|
||||
return $this->error(__('权限不足', 'aksrt'), 403);
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
return $this->error(__('标题不能为空', 'aksrt'));
|
||||
}
|
||||
|
||||
$result = \AKSRT\Ai\AISEO::get_instance()->suggest($title, $content);
|
||||
if (!empty($result['success'])) {
|
||||
return $this->success(['data' => $result['data']]);
|
||||
}
|
||||
|
||||
return $this->error((string) ($result['message'] ?? __('生成失败', 'aksrt')));
|
||||
}
|
||||
|
||||
public function summary_suggest(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Ai\AI') || !class_exists('\AKSRT\Ai\AIPrompt')) {
|
||||
return $this->error(__('AI 模块未加载', 'aksrt'), 500);
|
||||
@@ -224,7 +120,7 @@ class AIController extends ControllerBase {
|
||||
$ai = \AKSRT\Ai\AI::get_instance();
|
||||
if (!$ai->is_available()) {
|
||||
return $this->error(
|
||||
__('AI 服务未启用或未配置。请到「扩展 → AI 辅助」启用并填写 API Key(或配置 WordPress AI Connectors)。', 'aksrt'),
|
||||
__('AI 服务不可用,请配置 WordPress AI Connectors。', 'aksrt'),
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class GlobalSchema {
|
||||
'groups' => [
|
||||
['key' => 'basic', 'title' => 'SEO基础', 'fields' => [
|
||||
['key' => 'seo_enabled', 'label' => '启用核心SEO优化', 'type' => 'switch', 'default' => true, 'help' => '关闭后主题不再输出 SEO head 标签。'],
|
||||
['key' => 'seo_ai_enabled', 'label' => 'AI生成SEO内容', 'type' => 'switch', 'default' => false, 'help' => '开启后文章/分类独立 SEO 框显示 AI 生成按钮;需配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。', 'dependsOn' => ['key' => 'seo_enabled', 'value' => true]],
|
||||
['key' => 'seo_ai_enabled', 'label' => 'AI生成SEO内容', 'type' => 'switch', 'default' => false, 'help' => '开启后文章/分类独立 SEO 框显示 AI 生成按钮;需先在 WordPress 配置 AI Connectors。', 'dependsOn' => ['key' => 'seo_enabled', 'value' => true]],
|
||||
['key' => 'seo_connector', 'label' => 'SEO连接符', 'type' => 'input', 'default' => '-', 'help' => 'AI 生成标题时用于追加站点名。'],
|
||||
['key' => 'seo_title', 'label' => 'SEO标题(title)', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'seo_keywords', 'label' => 'SEO关键字(keywords)', 'type' => 'textarea', 'default' => ''],
|
||||
|
||||
+9
-314
@@ -11,9 +11,6 @@ class AI {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
const PROVIDER_NONE = 'none';
|
||||
const PROVIDER_OPENAI = 'openai';
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
@@ -21,95 +18,10 @@ class AI {
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 配置(主题侧 provider)
|
||||
*/
|
||||
public function get_config(): array {
|
||||
$defaults = [
|
||||
'enabled' => false,
|
||||
'provider' => self::PROVIDER_NONE,
|
||||
'api_key' => '',
|
||||
'api_base' => 'https://api.openai.com/v1',
|
||||
'model' => 'gpt-3.5-turbo',
|
||||
'max_tokens' => 1000,
|
||||
'rate_limit' => 10,
|
||||
];
|
||||
|
||||
$saved = get_option('aksrt_ai_settings', []);
|
||||
if (!is_array($saved)) {
|
||||
$saved = [];
|
||||
}
|
||||
|
||||
return array_merge($defaults, $saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 AI 配置
|
||||
*/
|
||||
public function update_config(array $config): bool {
|
||||
$current = $this->get_config();
|
||||
$merged = array_merge($current, $config);
|
||||
|
||||
// Preserve existing key when client sends the masked placeholder.
|
||||
if (isset($config['api_key']) && $config['api_key'] === '***') {
|
||||
$merged['api_key'] = $current['api_key'];
|
||||
}
|
||||
|
||||
$merged['enabled'] = !empty($merged['enabled']);
|
||||
$merged['provider'] = sanitize_key((string) ($merged['provider'] ?? self::PROVIDER_NONE));
|
||||
$merged['api_key'] = (string) ($merged['api_key'] ?? '');
|
||||
$merged['api_base'] = $this->normalize_api_base((string) ($merged['api_base'] ?? 'https://api.openai.com/v1'));
|
||||
$merged['model'] = sanitize_text_field((string) ($merged['model'] ?? 'gpt-3.5-turbo'));
|
||||
$merged['max_tokens'] = max(100, min(4000, (int) ($merged['max_tokens'] ?? 1000)));
|
||||
$merged['rate_limit'] = max(1, min(100, (int) ($merged['rate_limit'] ?? 10)));
|
||||
|
||||
// Persist only the known keys so shape stays stable.
|
||||
$to_store = [
|
||||
'enabled' => $merged['enabled'],
|
||||
'provider' => $merged['provider'],
|
||||
'api_key' => $merged['api_key'],
|
||||
'api_base' => $merged['api_base'],
|
||||
'model' => $merged['model'],
|
||||
'max_tokens' => $merged['max_tokens'],
|
||||
'rate_limit' => $merged['rate_limit'],
|
||||
];
|
||||
|
||||
$before = get_option('aksrt_ai_settings', []);
|
||||
$updated = update_option('aksrt_ai_settings', $to_store);
|
||||
if ($updated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// update_option returns false when the value is unchanged — treat as success.
|
||||
if (is_array($before)) {
|
||||
$before_normalized = array_merge([
|
||||
'enabled' => false,
|
||||
'provider' => self::PROVIDER_NONE,
|
||||
'api_key' => '',
|
||||
'api_base' => 'https://api.openai.com/v1',
|
||||
'model' => 'gpt-3.5-turbo',
|
||||
'max_tokens' => 1000,
|
||||
'rate_limit' => 10,
|
||||
], $before);
|
||||
$before_normalized['enabled'] = !empty($before_normalized['enabled']);
|
||||
$before_normalized['provider'] = sanitize_key((string) ($before_normalized['provider'] ?? self::PROVIDER_NONE));
|
||||
$before_normalized['api_key'] = (string) ($before_normalized['api_key'] ?? '');
|
||||
$before_normalized['api_base'] = esc_url_raw((string) ($before_normalized['api_base'] ?? 'https://api.openai.com/v1'));
|
||||
$before_normalized['model'] = sanitize_text_field((string) ($before_normalized['model'] ?? 'gpt-3.5-turbo'));
|
||||
$before_normalized['max_tokens'] = max(100, min(4000, (int) ($before_normalized['max_tokens'] ?? 1000)));
|
||||
$before_normalized['rate_limit'] = max(1, min(100, (int) ($before_normalized['rate_limit'] ?? 10)));
|
||||
|
||||
return maybe_serialize($before_normalized) === maybe_serialize($to_store)
|
||||
|| $before_normalized == $to_store;
|
||||
}
|
||||
|
||||
return maybe_serialize($before) === maybe_serialize($to_store);
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress 核心 AI Client 是否真正可用。
|
||||
*
|
||||
* 仅有函数存根不够:没有 Connectors / wp_supports_ai=false 时不应抢占主题 provider。
|
||||
* 仅有函数存根不够:没有 Connectors / wp_supports_ai=false 时不应声称可用。
|
||||
*/
|
||||
public function is_wp_ai_available(): bool {
|
||||
if (!function_exists('wp_ai_client_prompt')) {
|
||||
@@ -124,89 +36,24 @@ class AI {
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题 OpenAI 兼容 provider 是否可用。
|
||||
*/
|
||||
public function is_theme_provider_available(): bool {
|
||||
$config = $this->get_config();
|
||||
$provider = sanitize_key((string) ($config['provider'] ?? self::PROVIDER_NONE));
|
||||
$api_key = trim((string) ($config['api_key'] ?? ''));
|
||||
$api_base = trim((string) ($config['api_base'] ?? ''));
|
||||
|
||||
return !empty($config['enabled'])
|
||||
&& $provider !== self::PROVIDER_NONE
|
||||
&& $provider !== ''
|
||||
&& $api_key !== ''
|
||||
&& $api_base !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 任一后端可用即可生成。
|
||||
* AI 生成是否可用(连接由 WordPress AI Connectors 提供)。
|
||||
*/
|
||||
public function is_available(): bool {
|
||||
return $this->is_theme_provider_available() || $this->is_wp_ai_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前优先后端标识。
|
||||
*
|
||||
* Theme provider wins when explicitly configured — WP AI stubs often exist
|
||||
* without a working connector and previously produced opaque 400 failures.
|
||||
*/
|
||||
public function resolve_backend(): string {
|
||||
if ($this->is_theme_provider_available()) {
|
||||
return 'theme';
|
||||
}
|
||||
if ($this->is_wp_ai_available()) {
|
||||
return 'wp_ai';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 状态(不回显完整密钥)。
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : [];
|
||||
|
||||
return [
|
||||
'enabled' => !empty($config['enabled']),
|
||||
'provider' => (string) ($config['provider'] ?? self::PROVIDER_NONE),
|
||||
'configured' => !empty($config['api_key']) || $this->is_wp_ai_available(),
|
||||
'model' => (string) ($config['model'] ?? ''),
|
||||
'api_base' => (string) ($config['api_base'] ?? ''),
|
||||
'api_key' => !empty($config['api_key']) ? '***' : '',
|
||||
'max_tokens' => (int) ($config['max_tokens'] ?? 1000),
|
||||
'rate_limit' => (int) ($config['rate_limit'] ?? 10),
|
||||
'available' => $this->is_available(),
|
||||
'backend' => $this->resolve_backend(),
|
||||
'wp_ai_available' => $this->is_wp_ai_available(),
|
||||
'theme_available' => $this->is_theme_provider_available(),
|
||||
'seo_enabled' => !empty($seo['seo_enabled']),
|
||||
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']),
|
||||
'write_assist' => $this->is_available(),
|
||||
];
|
||||
return $this->is_wp_ai_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一文本生成出口。
|
||||
*
|
||||
* @param string $prompt 用户提示 / 上下文
|
||||
* @param array $options system_instruction, max_tokens, temperature
|
||||
* @param array $options system_instruction(其余选项由 WP AI Client 忽略)
|
||||
* @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'),
|
||||
'message' => __('AI 服务不可用,请配置 WordPress AI Connectors。', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -214,42 +61,12 @@ class AI {
|
||||
? (string) $options['system_instruction']
|
||||
: __('你是一个内容助手,提供建议性内容。输出仅作为参考建议,不会自动应用到线上内容。', 'aksrt');
|
||||
|
||||
$last_error = [
|
||||
'success' => false,
|
||||
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'),
|
||||
];
|
||||
|
||||
// 1) Prefer explicitly configured theme OpenAI-compatible provider.
|
||||
if ($this->is_theme_provider_available()) {
|
||||
$theme_result = $this->generate_via_theme($prompt, $system, $options);
|
||||
$this->record_call();
|
||||
if (!empty($theme_result['success'])) {
|
||||
$theme_result['backend'] = 'theme';
|
||||
return $theme_result;
|
||||
}
|
||||
$last_error = $theme_result;
|
||||
// Fall through to WP AI if available.
|
||||
$result = $this->generate_via_wp_ai($prompt, $system, $options);
|
||||
if (!empty($result['success'])) {
|
||||
$result['backend'] = 'wp_ai';
|
||||
}
|
||||
|
||||
// 2) WordPress AI Client (Connectors).
|
||||
if ($this->is_wp_ai_available()) {
|
||||
$wp_result = $this->generate_via_wp_ai($prompt, $system, $options);
|
||||
$this->record_call();
|
||||
if (!empty($wp_result['success'])) {
|
||||
$wp_result['backend'] = 'wp_ai';
|
||||
return $wp_result;
|
||||
}
|
||||
$last_error = $wp_result;
|
||||
}
|
||||
|
||||
return $last_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧调用。
|
||||
*/
|
||||
public function generate(string $prompt, array $options = []): array {
|
||||
return $this->generate_text($prompt, $options);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function generate_via_wp_ai(string $prompt, string $system, array $options): array {
|
||||
@@ -289,126 +106,4 @@ class AI {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OpenAI-compatible base URL.
|
||||
*
|
||||
* Accepts gateway root (https://host), /v1, trailing slash, or full
|
||||
* .../chat/completions and returns a base that can append /chat/completions.
|
||||
*/
|
||||
public function normalize_api_base(string $api_base): string {
|
||||
$api_base = trim($api_base);
|
||||
if ($api_base === '') {
|
||||
return 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
$api_base = esc_url_raw($api_base);
|
||||
$api_base = rtrim($api_base, '/');
|
||||
|
||||
// If user pasted the full chat endpoint, strip the leaf.
|
||||
if (preg_match('#/chat/completions$#i', $api_base)) {
|
||||
$api_base = (string) preg_replace('#/chat/completions$#i', '', $api_base);
|
||||
$api_base = rtrim($api_base, '/');
|
||||
}
|
||||
|
||||
// Common gateways (NewAPI etc.): root host needs /v1.
|
||||
if (!preg_match('#/(v1|openai/v1)$#i', $api_base)) {
|
||||
$api_base .= '/v1';
|
||||
}
|
||||
|
||||
return rtrim($api_base, '/');
|
||||
}
|
||||
|
||||
private function generate_via_theme(string $prompt, string $system, array $options): array {
|
||||
$config = $this->get_config();
|
||||
$max_tokens = (int) ($options['max_tokens'] ?? $config['max_tokens']);
|
||||
$temperature = (float) ($options['temperature'] ?? 0.7);
|
||||
$api_base = $this->normalize_api_base((string) $config['api_base']);
|
||||
|
||||
$response = wp_remote_post($api_base . '/chat/completions', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $config['api_key'],
|
||||
],
|
||||
'body' => wp_json_encode([
|
||||
'model' => $config['model'],
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
'max_tokens' => $max_tokens,
|
||||
'temperature' => $temperature,
|
||||
]),
|
||||
'timeout' => 45,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => sprintf(
|
||||
/* translators: %s: error message */
|
||||
__('API 请求失败: %s', 'aksrt'),
|
||||
$response->get_error_message()
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$body = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
$status_code = (int) wp_remote_retrieve_response_code($response);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$error_msg = is_array($body) ? ($body['error']['message'] ?? __('未知错误', 'aksrt')) : __('未知错误', 'aksrt');
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => sprintf(
|
||||
/* translators: 1: HTTP status, 2: error message */
|
||||
__('API 错误 (%1$d): %2$s', 'aksrt'),
|
||||
$status_code,
|
||||
$error_msg
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$message = is_array($body) ? ($body['choices'][0]['message'] ?? []) : [];
|
||||
$content = is_array($message) ? (string) ($message['content'] ?? '') : '';
|
||||
|
||||
if ($content === '' && is_array($message) && !empty($message['reasoning_content'])) {
|
||||
$content = (string) $message['reasoning_content'];
|
||||
}
|
||||
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI 返回内容为空(可能 max_tokens 不足)', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $content,
|
||||
'reasoning' => is_array($message) ? (string) ($message['reasoning_content'] ?? '') : '',
|
||||
'usage' => is_array($body) ? ($body['usage'] ?? []) : [],
|
||||
'finish_reason' => is_array($body) ? (string) ($body['choices'][0]['finish_reason'] ?? '') : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function check_rate_limit(): bool {
|
||||
$config = $this->get_config();
|
||||
$limit = (int) $config['rate_limit'];
|
||||
if ($limit <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$key = 'aksrt_ai_rate_' . get_current_user_id();
|
||||
$count = (int) get_transient($key);
|
||||
|
||||
return $count < $limit;
|
||||
}
|
||||
|
||||
private function record_call(): void {
|
||||
$key = 'aksrt_ai_rate_' . get_current_user_id();
|
||||
$count = (int) get_transient($key);
|
||||
set_transient($key, $count + 1, MINUTE_IN_SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,31 +77,6 @@ class AIPrompt {
|
||||
6. 直接输出描述文本本身,不要任何前缀、引号或解释。';
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧单 prompt 构建(草稿/无 ID 场景)。
|
||||
*/
|
||||
public function build_seo_prompt(string $title, string $content): string {
|
||||
$title_instruction = $this->get_seo_title_system_instruction();
|
||||
$kw_instruction = $this->get_seo_keywords_system_instruction();
|
||||
$desc_instruction = $this->get_seo_description_system_instruction();
|
||||
|
||||
return <<<PROMPT
|
||||
请根据以下文章内容,生成 SEO 优化建议。
|
||||
|
||||
文章标题:{$title}
|
||||
|
||||
文章内容:
|
||||
{$content}
|
||||
|
||||
请以 JSON 格式返回,包含以下字段:
|
||||
- suggested_title: {$title_instruction}
|
||||
- suggested_description: {$desc_instruction}
|
||||
- suggested_keywords: {$kw_instruction}(以 JSON 数组形式返回)
|
||||
|
||||
只返回 JSON,不要其他内容。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
public function build_summary_prompt(string $content, int $max_length = 200): string {
|
||||
$content = mb_substr($content, 0, 3000);
|
||||
return <<<PROMPT
|
||||
|
||||
@@ -107,67 +107,6 @@ class AISEO {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 草稿场景:用传入 title/content 生成(兼容旧 /ai/seo-suggest)。
|
||||
*/
|
||||
public function suggest(string $title, string $content = ''): array {
|
||||
if (!$this->is_enabled()) {
|
||||
return ['success' => false, 'message' => __('AI SEO 功能未启用', 'aksrt')];
|
||||
}
|
||||
|
||||
if (!class_exists('AKSRT\\Ai\\AI') || !class_exists('AKSRT\\Ai\\AIPrompt')) {
|
||||
return ['success' => false, 'message' => __('AI 模块未加载', 'aksrt')];
|
||||
}
|
||||
|
||||
$ai = AI::get_instance();
|
||||
if (!$ai->is_available()) {
|
||||
return ['success' => false, 'message' => __('AI 服务未启用', 'aksrt')];
|
||||
}
|
||||
|
||||
$title = trim($title);
|
||||
$content = trim(wp_strip_all_tags($content));
|
||||
if ($title === '' && $content === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('无可用内容,请先填写标题或正文', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($title !== '') {
|
||||
$parts[] = '标题: ' . $title;
|
||||
}
|
||||
if ($content !== '') {
|
||||
$content = wp_trim_words($content, 1500, '');
|
||||
$parts[] = "正文:\n" . $content;
|
||||
}
|
||||
$context = implode("\n\n", $parts);
|
||||
$prompt = AIPrompt::get_instance();
|
||||
|
||||
$title_out = $this->run_field($ai, $context, $prompt->get_seo_title_system_instruction(), 'title');
|
||||
$kw_out = $this->run_field($ai, $context, $prompt->get_seo_keywords_system_instruction(), 'keywords');
|
||||
$desc_out = $this->run_field($ai, $context, $prompt->get_seo_description_system_instruction(), 'description');
|
||||
|
||||
$keywords = [];
|
||||
if (!empty($kw_out['content'])) {
|
||||
$keywords = array_values(array_filter(array_map('trim', explode(',', $kw_out['content']))));
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'suggested_title' => (string) ($title_out['content'] ?? ''),
|
||||
'suggested_description' => (string) ($desc_out['content'] ?? ''),
|
||||
'suggested_keywords' => $keywords,
|
||||
'fields' => [
|
||||
'title' => $title_out,
|
||||
'keywords' => $kw_out,
|
||||
'description' => $desc_out,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
|
||||
@@ -210,16 +210,16 @@ class SEOMetaBox {
|
||||
true
|
||||
);
|
||||
|
||||
$status = class_exists('\AKSRT\Ai\AI') ? \AKSRT\Ai\AI::get_instance()->get_status() : [];
|
||||
$ai_available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
|
||||
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\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']),
|
||||
'available' => $ai_available,
|
||||
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']),
|
||||
'write_assist' => !empty($status['available']),
|
||||
'write_assist' => $ai_available,
|
||||
],
|
||||
'i18n' => [
|
||||
'generating' => __('生成中…', 'aksrt'),
|
||||
@@ -262,7 +262,7 @@ class SEOMetaBox {
|
||||
$available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
|
||||
$disabled = (!$available || $id <= 0) ? ' disabled' : '';
|
||||
$title = !$available
|
||||
? esc_attr__('请先配置 AI 连接(扩展 → AI 连接配置 或 WP Connectors)', 'aksrt')
|
||||
? esc_attr__('请先配置 WordPress AI Connectors', 'aksrt')
|
||||
: ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : '');
|
||||
|
||||
return sprintf(
|
||||
|
||||
@@ -106,11 +106,6 @@ if (file_exists($js)) {
|
||||
],
|
||||
|
||||
'imageUploadMultiple' => $image_upload_multiple,
|
||||
'ai' => [
|
||||
'available' => !empty($ctx['ai_available']),
|
||||
'summary' => !empty($ctx['ai_available']),
|
||||
'tags' => !empty($ctx['ai_available']),
|
||||
],
|
||||
'editor' => [
|
||||
'canUploadImage' => !empty($ctx['can_upload_img']),
|
||||
'canUploadVideo' => !empty($ctx['can_upload_video']),
|
||||
@@ -142,10 +137,6 @@ if (file_exists($js)) {
|
||||
'submitted' => __('投稿已提交', 'aksrt'),
|
||||
'error' => __('操作失败,请重试', 'aksrt'),
|
||||
'confirmDelete' => __('确定要删除这个草稿吗?', 'aksrt'),
|
||||
'aiGenerating' => __('生成中…', 'aksrt'),
|
||||
'aiFailed' => __('AI 生成失败', 'aksrt'),
|
||||
'needContent' => __('请先填写正文内容', 'aksrt'),
|
||||
'aiFilled' => __('已填入,可继续编辑', 'aksrt'),
|
||||
'deleting' => __('正在删除草稿…', 'aksrt'),
|
||||
'deleted' => __('草稿已删除', 'aksrt'),
|
||||
'uploading' => __('正在上传…', 'aksrt'),
|
||||
|
||||
@@ -177,8 +177,6 @@ if ($is_guest) {
|
||||
$submit_label = __('保存', 'aksrt');
|
||||
}
|
||||
|
||||
$ai_available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
|
||||
|
||||
return [
|
||||
'front' => $front,
|
||||
'user_id' => $user_id,
|
||||
@@ -207,7 +205,6 @@ return [
|
||||
'vip' => $vip,
|
||||
'points_enabled' => $points_enabled,
|
||||
'submit_label' => $submit_label,
|
||||
'ai_available' => $ai_available,
|
||||
'can_pay' => $cap('new_post_pay'),
|
||||
'can_pay_download' => $cap('new_post_pay_download'),
|
||||
'can_cover_image' => $cap('new_post_image_cover'),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
/** AI 连接配置页已移除:主题只走 WordPress AI Connectors,生成能力与入口保留。 */
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
|
||||
const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
|
||||
|
||||
const ai = read('src/Ai/AI.php');
|
||||
const controller = read('src/Admin/AIController.php');
|
||||
const seoSchema = read('assets/admin/src/schema/sections/global/seo.js');
|
||||
const extensionsMenu = read('assets/admin/src/constants/sections/extensions.js');
|
||||
const apiModule = read('assets/admin/src/api/modules/extensions.js');
|
||||
const frontSubmit = read('assets/js/front-submit.js');
|
||||
const submitContext = read('templates/parts/submit/context.php');
|
||||
const submitPage = read('templates/pages/page-submit.php');
|
||||
|
||||
const removed = [
|
||||
'aksrt_ai_settings',
|
||||
'AI 连接配置',
|
||||
'AI连接配置',
|
||||
'ExtensionAI',
|
||||
'extensions-ai',
|
||||
'/ai/settings',
|
||||
'/ai/status',
|
||||
'ai-assist-btn',
|
||||
'扩展 → AI',
|
||||
];
|
||||
|
||||
const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return ['node_modules', 'build', 'dist'].includes(entry.name) ? [] : walk(full);
|
||||
}
|
||||
return [full];
|
||||
});
|
||||
|
||||
const sources = ['src', 'assets/admin/src', 'templates', 'assets/js', 'inc']
|
||||
.flatMap((dir) => walk(path.join(root, dir)));
|
||||
|
||||
test('后台菜单、路由与 API 中不再有 AI 连接配置页', () => {
|
||||
assert.doesNotMatch(extensionsMenu, /extensions-ai|ExtensionAI|AI 连接配置/);
|
||||
assert.doesNotMatch(apiModule, /getAiStatus|updateAiSettings|generateSeo|suggestSummary|suggestTags/);
|
||||
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/pages/extensions/ExtensionAI.vue')));
|
||||
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/router/component-loaders/extensions.js')));
|
||||
});
|
||||
|
||||
test('AI.php 只保留 WP AI Client 单路出口', () => {
|
||||
assert.doesNotMatch(ai, /PROVIDER_OPENAI|PROVIDER_NONE/);
|
||||
assert.doesNotMatch(ai, /get_config|update_config/);
|
||||
assert.doesNotMatch(ai, /generate_via_theme|normalize_api_base/);
|
||||
assert.doesNotMatch(ai, /is_theme_provider_available|resolve_backend/);
|
||||
assert.doesNotMatch(ai, /check_rate_limit|record_call/);
|
||||
assert.match(ai, /wp_ai_client_prompt/);
|
||||
assert.match(ai, /function is_wp_ai_available/);
|
||||
assert.match(ai, /function is_available/);
|
||||
});
|
||||
|
||||
test('生成路由保留,连接配置与旧草稿兼容路由移除', () => {
|
||||
assert.match(controller, /\/ai\/seo-generate/);
|
||||
assert.match(controller, /\/ai\/summary-suggest/);
|
||||
assert.match(controller, /\/ai\/tags-suggest/);
|
||||
assert.doesNotMatch(controller, /\/ai\/seo-suggest/);
|
||||
assert.doesNotMatch(controller, /function get_status|function update_settings|function seo_suggest/);
|
||||
});
|
||||
|
||||
test('残留文案不再指向已删除的配置页', () => {
|
||||
for (const [name, source] of [['AI.php', ai], ['AIController.php', controller], ['seo.js', seoSchema]]) {
|
||||
assert.doesNotMatch(source, /AI 连接配置/, `${name} 仍引用 AI 连接配置页`);
|
||||
assert.doesNotMatch(source, /扩展 → AI/, `${name} 仍引导到扩展菜单`);
|
||||
}
|
||||
});
|
||||
|
||||
test('前台投稿 AI 辅助死代码已清理', () => {
|
||||
assert.doesNotMatch(frontSubmit, /bindAi|ai-assist-btn|cfg\.ai/);
|
||||
assert.doesNotMatch(submitContext, /ai_available/);
|
||||
assert.doesNotMatch(submitPage, /'ai'\s*=>|aiGenerating|aiFailed|aiFilled|needContent/);
|
||||
});
|
||||
|
||||
test('全仓源码不再引用已删除的 AI 连接配置', () => {
|
||||
const offenders = sources.filter((file) => {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
return removed.some((pattern) => text.includes(pattern));
|
||||
});
|
||||
assert.deepEqual(offenders.map((file) => path.relative(root, file)), []);
|
||||
});
|
||||
@@ -379,8 +379,6 @@ test('front submit actions use direct fixed-id notyf flows', () => {
|
||||
'aksrt-front-submit-delete-draft-toast',
|
||||
'aksrt-front-submit-resource-upload-toast',
|
||||
'aksrt-front-submit-editor-media-toast',
|
||||
'aksrt-front-submit-ai-tags-toast',
|
||||
'aksrt-front-submit-ai-summary-toast',
|
||||
].forEach((id) => assert.match(source, new RegExp("['\"]" + id + "['\"]")));
|
||||
|
||||
assert.match(submit, /submit: function[\s\S]*?notyf\([^;]+?'load'[^;]+?toastId[\s\S]*?notyf\([^;]+?'success'[^;]+?toastId[\s\S]*?notyf\([^;]+?'danger'[^;]+?toastId/);
|
||||
@@ -398,8 +396,6 @@ test('front submit actions use direct fixed-id notyf flows', () => {
|
||||
// 付费下载附件选择后仅补一条成功确认,上传过程通知属于选择器。
|
||||
assert.match(downloads, /AksrtSubmitMedia\.open\([\s\S]*?'success', 5, 'aksrt-front-submit-resource-upload-toast'/);
|
||||
assert.match(editor, /aksrt-front-submit-editor-media-toast[\s\S]*?'load'[\s\S]*?'success'[\s\S]*?'danger'/);
|
||||
assert.match(submit, /aksrt-front-submit-ai-tags-toast[\s\S]*?aksrt-front-submit-ai-summary-toast[\s\S]*?'load'[\s\S]*?'success'[\s\S]*?'danger'/);
|
||||
assert.match(submit, /needContent[^;]+?'warning'/);
|
||||
assert.doesNotMatch(submit, /\b(?:window\.)?alert\s*\(/);
|
||||
|
||||
assert.match(page, /'aksrt-front-submit',[\s\S]*?'\/assets\/js\/front-submit\.js',[\s\S]*?\[[^\]]*'loader_js'[^\]]*\]/);
|
||||
|
||||
@@ -144,7 +144,6 @@ test('retired admin pages, schemas, APIs, and metadata chains are absent', () =>
|
||||
'assets/admin/src/constants/sections/extensions.js',
|
||||
'assets/admin/src/router/component-loaders/users.js',
|
||||
'assets/admin/src/router/component-loaders/tools.js',
|
||||
'assets/admin/src/router/component-loaders/extensions.js',
|
||||
'assets/admin/src/api/modules/tools.js',
|
||||
'assets/admin/src/schema/sections/tools/index.js',
|
||||
'src/Admin/Schema/SchemaTools.php',
|
||||
|
||||
Reference in New Issue
Block a user