diff --git a/assets/admin/src/api/modules/extensions.js b/assets/admin/src/api/modules/extensions.js index 36c98474..e9e2f50e 100644 --- a/assets/admin/src/api/modules/extensions.js +++ b/assets/admin/src/api/modules/extensions.js @@ -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 }) }, } diff --git a/assets/admin/src/constants/sections/extensions.js b/assets/admin/src/constants/sections/extensions.js index 5e8b49d1..2cc99dd7 100644 --- a/assets/admin/src/constants/sections/extensions.js +++ b/assets/admin/src/constants/sections/extensions.js @@ -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 框,摘要/标签在前台投稿页。', - }), ], } diff --git a/assets/admin/src/pages/extensions/ExtensionAI.vue b/assets/admin/src/pages/extensions/ExtensionAI.vue deleted file mode 100644 index 6f46c745..00000000 --- a/assets/admin/src/pages/extensions/ExtensionAI.vue +++ /dev/null @@ -1,316 +0,0 @@ - - - - - diff --git a/assets/admin/src/router/component-loaders/extensions.js b/assets/admin/src/router/component-loaders/extensions.js deleted file mode 100644 index fde06c61..00000000 --- a/assets/admin/src/router/component-loaders/extensions.js +++ /dev/null @@ -1,3 +0,0 @@ -export const extensionsPageLoaders = { - ExtensionAI: () => import('@/pages/extensions/ExtensionAI.vue'), -} diff --git a/assets/admin/src/router/component-loaders/index.js b/assets/admin/src/router/component-loaders/index.js index 11230ac7..75c5d171 100644 --- a/assets/admin/src/router/component-loaders/index.js +++ b/assets/admin/src/router/component-loaders/index.js @@ -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, } diff --git a/assets/admin/src/schema/sections/global/seo.js b/assets/admin/src/schema/sections/global/seo.js index 8ffcff97..bc4db31a 100644 --- a/assets/admin/src/schema/sections/global/seo.js +++ b/assets/admin/src/schema/sections/global/seo.js @@ -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 生成标题时会用此连接符追加站点名。' }, diff --git a/assets/js/front-submit.js b/assets/js/front-submit.js index 24cd967f..0404ea9e 100644 --- a/assets/js/front-submit.js +++ b/assets/js/front-submit.js @@ -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(); }); diff --git a/src/Admin/AIController.php b/src/Admin/AIController.php index 524478b0..951667e6 100644 --- a/src/Admin/AIController.php +++ b/src/Admin/AIController.php @@ -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 ); } diff --git a/src/Admin/Schema/GlobalSchema.php b/src/Admin/Schema/GlobalSchema.php index bfdbbe9a..c17e2005 100644 --- a/src/Admin/Schema/GlobalSchema.php +++ b/src/Admin/Schema/GlobalSchema.php @@ -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' => ''], diff --git a/src/Ai/AI.php b/src/Ai/AI.php index 064f1b75..95b71fa1 100644 --- a/src/Ai/AI.php +++ b/src/Ai/AI.php @@ -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); - } } diff --git a/src/Ai/AIPrompt.php b/src/Ai/AIPrompt.php index a82f22d9..09cf023d 100644 --- a/src/Ai/AIPrompt.php +++ b/src/Ai/AIPrompt.php @@ -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 <<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 */ diff --git a/src/Core/SEOMetaBox.php b/src/Core/SEOMetaBox.php index 594d3d43..418953ea 100644 --- a/src/Core/SEOMetaBox.php +++ b/src/Core/SEOMetaBox.php @@ -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( diff --git a/templates/pages/page-submit.php b/templates/pages/page-submit.php index 0571bdc4..8efa74ae 100644 --- a/templates/pages/page-submit.php +++ b/templates/pages/page-submit.php @@ -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'), diff --git a/templates/parts/submit/context.php b/templates/parts/submit/context.php index 2fa17a86..18b761e3 100644 --- a/templates/parts/submit/context.php +++ b/templates/parts/submit/context.php @@ -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'), diff --git a/tests/admin/ai-config-removal.test.cjs b/tests/admin/ai-config-removal.test.cjs new file mode 100644 index 00000000..55a9f0dc --- /dev/null +++ b/tests/admin/ai-config-removal.test.cjs @@ -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)), []); +}); diff --git a/tests/widget-system/toast-contract.test.cjs b/tests/widget-system/toast-contract.test.cjs index 5cd65046..a6f8837c 100644 --- a/tests/widget-system/toast-contract.test.cjs +++ b/tests/widget-system/toast-contract.test.cjs @@ -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'[^\]]*\]/); diff --git a/tests/widget-system/user-features-contract.test.cjs b/tests/widget-system/user-features-contract.test.cjs index 04e2bdc0..06b0ef70 100644 --- a/tests/widget-system/user-features-contract.test.cjs +++ b/tests/widget-system/user-features-contract.test.cjs @@ -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',