Files
wordpress-theme-aksrt/inc/classes/class-aksrt-ai.php
T
kate522 48f089cdc7 fix(ai): normalize OpenAI-compatible API base to /v1
Accept gateway root URLs like https://llm.example.com and auto-append /v1
so NewAPI-style providers work; update Extension AI help text accordingly.
2026-07-13 08:53:33 +08:00

419 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* AKSRT AI — AI 子系统核心
*
* 统一管理 AI 调用:优先 WordPress AI Client(Connectors),
* 回退主题 OpenAI 兼容配置。默认关闭,需管理员显式启用主题 provider
* 或配置核心 Connectors。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_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();
}
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。
*/
public function is_wp_ai_available(): bool {
if (!function_exists('wp_ai_client_prompt')) {
return false;
}
// Prefer the official capability gate when present.
if (function_exists('wp_supports_ai')) {
return (bool) wp_supports_ai();
}
// Without wp_supports_ai, do not claim availability (avoids false-positive 400s).
return false;
}
/**
* 主题 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 !== '';
}
/**
* 任一后端可用即可生成。
*/
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') ? AKSRT_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(),
];
}
/**
* 统一文本生成出口。
*
* @param string $prompt 用户提示 / 上下文
* @param array $options system_instruction, max_tokens, temperature
* @return array{success:bool,content?:string,message?:string,backend?:string}
*/
public function generate_text(string $prompt, array $options = []): array {
if (!$this->is_available()) {
return [
'success' => false,
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'),
];
}
if (!$this->check_rate_limit()) {
return [
'success' => false,
'message' => __('请求过于频繁,请稍后再试', 'aksrt'),
];
}
$system = isset($options['system_instruction'])
? (string) $options['system_instruction']
: __('你是一个内容助手,提供建议性内容。输出仅作为参考建议,不会自动应用到线上内容。', 'aksrt');
$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.
}
// 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);
}
private function generate_via_wp_ai(string $prompt, string $system, array $options): array {
try {
$builder = wp_ai_client_prompt($prompt);
if (is_object($builder) && method_exists($builder, 'using_system_instruction') && $system !== '') {
$builder = $builder->using_system_instruction($system);
}
if (!is_object($builder) || !method_exists($builder, 'generate_text')) {
return [
'success' => false,
'message' => __('WordPress AI Client 接口不可用', 'aksrt'),
];
}
$text = $builder->generate_text();
if (is_wp_error($text)) {
return [
'success' => false,
'message' => $text->get_error_message(),
];
}
$text = is_string($text) ? trim($text) : '';
if ($text === '') {
return [
'success' => false,
'message' => __('AI 未返回任何文本', 'aksrt'),
];
}
return [
'success' => true,
'content' => $text,
];
} catch (Throwable $t) {
return [
'success' => false,
'message' => $t->getMessage() ?: __('WordPress AI Client 调用失败', 'aksrt'),
];
}
}
/**
* 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);
}
}