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:
2026-10-13 19:32:34 +08:00
parent bd01199930
commit cdab7a5e5f
18 changed files with 110 additions and 931 deletions
+1 -105
View File
@@ -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
);
}
+1 -1
View File
@@ -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
View File
@@ -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);
}
}
-25
View File
@@ -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
-61
View File
@@ -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
*/
+5 -5
View File
@@ -210,16 +210,16 @@ class SEOMetaBox {
true
);
$status = class_exists('\AKSRT\Ai\AI') ? \AKSRT\Ai\AI::get_instance()->get_status() : [];
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : [];
$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(