is_supported_for_text_generation(); } } catch ( \Throwable $e ) { $cached = false; } } return $cached; } /** * 本次翻译是不是走 WordPress 自带的 AI Client。 * * @return bool */ public static function use_core_ai() { $ai_cfg = APGT_Settings::get_ai(); $provider = isset( $ai_cfg['provider'] ) ? (string) $ai_cfg['provider'] : ''; if ( 'plugin' === $provider ) { return false; } if ( 'service' === $provider ) { // 订阅由服务端代调模型,跟本站配没配 AI 无关 —— 不能像「自动」那样 // 落到站点级 AI 上,否则用户明明买了订阅,翻译却走了别处。 return false; } if ( 'core' === $provider ) { // 明确选了「只用站点的」就不去猜,站点没配好也照样走过去、由它报错; // 唯一的例外是这个 WordPress 压根没有 AI Client(7.0 以前),那时 // 站点级配置无从谈起,只能回到插件自己的设置。 return self::core_ai_supported(); } // 自动:站点配好了就用站点的。 return self::core_ai_available(); } /** * 通过 WordPress 自带的 AI Client 请求一次文本生成。 * * 模型、密钥、代理全部由站长在站点层面配好,插件只管把指令递过去。 * * @param string $prompt 完整指令。 * @param int $max_tokens 单次输出上限,0 表示跟随站点配置。 * @return array [ 'ok'=>bool, 'content'=>string, 'message'=>string, 'fatal'=>bool ] */ private static function ask_core_ai( $prompt, $max_tokens = 0 ) { $text = null; if ( self::core_ai_supported() ) { try { $builder = self::call_core_ai( self::CORE_AI_PROMPT, $prompt ); if ( $max_tokens > 0 ) { $builder = $builder->using_max_tokens( $max_tokens ); } $text = $builder->generate_text(); } catch ( \Throwable $e ) { $err = self::request_error( $e->getMessage() ); return array( 'ok' => false, 'content' => '', 'message' => $err['message'], 'fatal' => $err['fatal'], ); } } if ( null === $text ) { return array( 'ok' => false, 'content' => '', 'message' => __( 'This WordPress version has no built-in AI client. Switch this plugin back to its own AI service settings.', 'aipogeo-context-translate' ), 'fatal' => true, ); } if ( is_wp_error( $text ) ) { $err = self::request_error( $text->get_error_message() ); return array( 'ok' => false, 'content' => '', 'message' => $err['message'], 'fatal' => $err['fatal'], ); } return array( 'ok' => true, 'content' => trim( (string) $text ), 'message' => '', 'fatal' => false, ); } /** * php-ai 库内置的模型清单。 * * 模型名与接口地址都从库里现取,库里加了新模型后台就能看到,不用两头维护。 * * @return array 模型名 => 内置接口地址 */ public static function builtin_models() { static $map = null; if ( null !== $map ) { return $map; } $map = array(); self::boot_ai(); if ( ! class_exists( '\Ai\AI' ) ) { return $map; } try { // 未指定模型时 listModels() 返回内置模型标识清单,不发起网络请求。 $names = ( new \Ai\AI() )->listModels(); } catch ( \Throwable $e ) { return $map; } foreach ( (array) $names as $name ) { try { $map[ $name ] = \Ai\AI::create( array( 'model' => $name ) )->resolveEndpoint(); } catch ( \Throwable $e ) { continue; } } return $map; } /** * 某个模型的内置接口地址。 * * @param string $model 模型名。 * @return string 未知模型返回空串。 */ public static function builtin_endpoint( $model ) { $map = self::builtin_models(); return isset( $map[ $model ] ) ? $map[ $model ] : ''; } /** * 库支持的协议格式清单。 * * 接第三方中转站/自建网关时,模型名往往认不出所属平台(如 `my-gpt`、`qwen-max`), * 这时由用户手选按哪种协议通信。 * * @return array 协议标识 => 显示名。 */ public static function protocols() { static $list = null; if ( null !== $list ) { return $list; } $list = array(); self::boot_ai(); if ( ! class_exists( '\Ai\AI' ) ) { return $list; } try { $list = ( new \Ai\AI() )->listProtocols(); } catch ( \Throwable $e ) { $list = array(); } return $list; } /** * 各协议的官方对话端点,供后台在手选协议后填出默认地址。 * * @return array 协议标识 => 官方端点。 */ public static function protocol_endpoints() { static $map = null; if ( null !== $map ) { return $map; } $map = array(); foreach ( array_keys( self::protocols() ) as $key ) { try { $map[ $key ] = \Ai\Helpers\Protocols::endpointOf( $key ); } catch ( \Throwable $e ) { continue; } } return $map; } /** * 某个协议的官方对话端点。 * * @param string $protocol 协议标识。 * @return string 未知协议返回空串。 */ public static function protocol_endpoint( $protocol ) { $map = self::protocol_endpoints(); return isset( $map[ $protocol ] ) ? $map[ $protocol ] : ''; } /** * 当前设置对应的默认接口地址。 * * 手选了协议就以协议官方端点为准(这时模型名可能是网关自定的,认不出平台), * 否则用模型的内置地址。 * * @param array $ai_cfg AI 设置。 * @return string */ public static function default_endpoint( array $ai_cfg ) { $protocol = isset( $ai_cfg['protocol'] ) ? trim( (string) $ai_cfg['protocol'] ) : ''; if ( '' !== $protocol ) { return self::protocol_endpoint( $protocol ); } return self::builtin_endpoint( isset( $ai_cfg['model'] ) ? $ai_cfg['model'] : '' ); } /** * 把插件的 AI 设置翻成 php-ai 库的配置数组。 * * 接口地址填完整端点(`…/v1/chat/completions`)时原样请求;只填到 `…/v1` * 这类根地址时交给库按协议补对话路径,中转站给什么地址都能直接粘。 * * @param array $ai_cfg AI 设置。 * @return array */ public static function ai_config( array $ai_cfg ) { $conf = array( 'model' => isset( $ai_cfg['model'] ) ? trim( (string) $ai_cfg['model'] ) : '', 'api_key' => isset( $ai_cfg['api_key'] ) ? (string) $ai_cfg['api_key'] : '', ); $protocol = isset( $ai_cfg['protocol'] ) ? trim( (string) $ai_cfg['protocol'] ) : ''; if ( '' !== $protocol ) { $conf['protocol'] = $protocol; } $url = isset( $ai_cfg['api_url'] ) ? trim( (string) $ai_cfg['api_url'] ) : ''; if ( '' === $url && '' !== $protocol ) { // 手选了协议又没填地址时,明确指定该协议的官方端点。 // 不指定的话,库会拿「模型名推断出的平台根地址」去拼「所选协议的路径」, // 手选协议与模型名不是一路时会拼出不存在的地址(gpt-4o + claude => api.openai.com/v1/messages)。 $url = self::protocol_endpoint( $protocol ); } if ( '' !== $url ) { $conf[ self::is_chat_endpoint( $url ) ? 'endpoint' : 'base_url' ] = $url; } if ( isset( $ai_cfg['max_tokens'] ) && (int) $ai_cfg['max_tokens'] > 0 ) { $conf['max_tokens'] = (int) $ai_cfg['max_tokens']; } return $conf; } /** * 地址看着是不是「完整对话端点」。 * * 按路径末段判断:completions / messages / chat 都是各协议的对话动作后缀, * 到这一层就说明用户给的是最终请求地址,不该再往后拼路径。 * * @param string $url 接口地址。 * @return bool */ private static function is_chat_endpoint( $url ) { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $segs = array_values( array_filter( explode( '/', trim( $path, '/' ) ), 'strlen' ) ); if ( empty( $segs ) ) { return false; } return in_array( strtolower( end( $segs ) ), array( 'completions', 'messages', 'chat' ), true ); } /** * 把 AI 请求异常翻成能照着改的提示。 * * 目前只认「max_tokens 超上限」这一种:各平台的输出上限差得很远, * 设置里填死一个大数换个模型就会被拒,而平台只回一句英文报错, * 光看不出来该去改哪个设置项。 * * @param string $raw 异常原文。 * @return array [ 'message' => string, 'fatal' => bool ] fatal 表示拆批重试没有意义。 */ private static function request_error( $raw ) { if ( preg_match( '/max_tokens[^\[]*\[\s*\d+\s*,\s*(\d+)\s*\]/i', $raw, $m ) ) { return array( 'message' => sprintf( /* translators: 1: limit allowed by the provider, 2: original error */ __( 'Max output tokens in the settings exceeds this model\'s limit (at most %1$s). Lower it, or enter 0 to follow the model\'s built-in value. Original error: %2$s', 'aipogeo-context-translate' ), number_format_i18n( (int) $m[1] ), $raw ), 'fatal' => true, ); } return array( /* translators: %s: original error */ 'message' => sprintf( __( 'AI request failed: %s', 'aipogeo-context-translate' ), $raw ), 'fatal' => false, ); } /** * 当前设置实际会请求的地址,供后台回显。 * * 由库自己解析,界面上看到的就是真正发出去的地址。 * * @param array $ai_cfg AI 设置。 * @return array [ 'url' => string, 'error' => string ] */ public static function endpoint_preview( array $ai_cfg ) { self::boot_ai(); if ( ! class_exists( '\Ai\AI' ) ) { return array( 'url' => '', 'error' => __( 'The AI library is not loaded. Run composer install in the plugin directory.', 'aipogeo-context-translate' ), ); } try { return array( 'url' => \Ai\AI::create( self::ai_config( $ai_cfg ) )->resolveEndpoint(), 'error' => '', ); } catch ( \Throwable $e ) { return array( 'url' => '', 'error' => $e->getMessage(), ); } } /** * 翻译一个页面。 * * @param string $url 站内页面 URL(源语言版本)。 * @param string $to 目标语言 iso。 * @return array [ 'ok'=>bool, 'message'=>string, 'saved'=>int, 'skipped'=>int, 'failed'=>int, 'errors'=>array ] */ public static function translate_page( $url, $to ) { $from = APGT_Settings::default_language(); if ( '' === $from ) { return self::result( false, __( 'The site default language is not set', 'aipogeo-context-translate' ) ); } if ( '' === $to || $to === $from ) { return self::result( false, __( 'Invalid target language', 'aipogeo-context-translate' ) ); } // 库里 source_url 一律存站内路径,换域名后「已翻译」标记才不会全部失效。 $path = APGT_Pages::to_relative( $url ); if ( ! APGT_Pages::is_site_path( $path ) ) { return self::result( false, __( 'Invalid page URL', 'aipogeo-context-translate' ) ); } // 抓取页面源 HTML。 $fetch = self::fetch_html( APGT_Pages::to_absolute( $path ) ); if ( ! $fetch['ok'] ) { // 这条错误会一路传到批量翻译和 worker 的状态栏,带上现场摘要才看得出是哪一环出的问题。 return self::result( false, $fetch['message'] . self::debug_summary( $fetch['debug'] ) ); } // 提取待翻译文本;URL 路径只在设置里开启后才一并提取。 $texts = APGT_Html::extract( $fetch['html'], self::translate_urls_enabled(), $types ); if ( empty( $texts ) ) { // 记成 0/0,避免这一页每轮都被当成待办反复抓取。 APGT_Progress::save( $path, $to, 0, 0 ); return self::result( true, __( 'This page has no translatable text', 'aipogeo-context-translate' ) ); } // 过滤:已 AI/manual 翻译的跳过;超长的跳过。 $pending = array(); $skipped = 0; foreach ( $texts as $md5 => $text ) { if ( mb_strlen( $text, 'UTF-8' ) > self::MAX_TEXT_LENGTH ) { ++$skipped; continue; } if ( APGT_Store::is_translated( $text, $from, $to ) ) { ++$skipped; continue; } $pending[ $md5 ] = $text; } if ( empty( $pending ) ) { self::record_progress( $path, $to, $texts, $from ); return self::result( true, __( 'Every text on this page is already translated', 'aipogeo-context-translate' ), 0, $skipped ); } // 调 AI 翻译。 $text_replace = self::text_replace_rules( $to ); $tr = self::translate_texts( $pending, $from, $to, $text_replace ); // 入库。 $saved = 0; foreach ( $tr['trans'] as $md5 => $trans_text ) { if ( '' === trim( $trans_text ) || ! isset( $pending[ $md5 ] ) ) { continue; } $ok = APGT_Store::save( array( 'text_source' => $pending[ $md5 ], 'text_trans' => $trans_text, 'language_from' => $from, 'language_to' => $to, 'source_url' => $path, 'engine' => 'ai', 'type' => isset( $types[ $md5 ] ) ? $types[ $md5 ] : 'text', ) ); if ( $ok ) { ++$saved; } } $failed = count( $pending ) - $saved; // 记录这一页的完成度。按 md5 重新查一遍,把别的页面顺带译好的共享文本也算上。 self::record_progress( $path, $to, $texts, $from ); $message = sprintf( /* translators: 1: succeeded, 2: skipped, 3: failed */ __( '%1$d succeeded, %2$d skipped, %3$d failed', 'aipogeo-context-translate' ), $saved, $skipped, $failed ); return self::result( true, $message, $saved, $skipped, $failed, $tr['errors'] ); } /** * 统计并记录某页面的翻译完成度。 * * done 按原文 md5 现查,所以别的页面顺带译好的共享文本也会被算进来。 * * @param string $path 站内路径。 * @param string $to 目标语言。 * @param array $texts md5 => 原文(该页面提取到的全部文本)。 * @param string $from 源语言。 * @return void */ public static function record_progress( $path, $to, array $texts, $from ) { $map = APGT_Store::get_trans_map( array_keys( $texts ), $from, $to ); APGT_Progress::save( $path, $to, count( $texts ), count( $map ) ); } /** * 进入长时间翻译前的环境准备。 * * 关键是 ignore_user_abort:PHP 默认在客户端断开后就把脚本掐掉,而一次 * 翻译往往要等 AI 几十秒。访客看着慢随手关掉页面、或管理员切走页面, * 都会让这次翻译半途而废——token 已经花了,结果却没入库,下次访问 * 又得重来一遍。所以断开归断开,翻译照跑完、照入库。 * * @return void */ public static function prepare_long_task() { if ( function_exists( 'ignore_user_abort' ) ) { ignore_user_abort( true ); } if ( function_exists( 'set_time_limit' ) ) { // 一次整页翻译要等 AI 几十秒到几分钟,不解除时限必然被 PHP 中途掐断。 @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, Squiz.PHP.DiscouragedFunctions.Discouraged } } /** * 「翻译 URL 路径」是否已开启。 * * @return bool */ public static function translate_urls_enabled() { $settings = APGT_Settings::get_settings(); return ! empty( $settings['is_trans_url'] ); } /** * 抓取站内页面的源语言 HTML。 * * 抓的就是页面本身的地址,一个字符都不加。以前会在后面挂个 apgt_src=1 当标记, * 但不少站点的伪静态规则或安全防护见到不认识的查询参数就回 404——同一个地址 * 浏览器打得开、插件抓就是 404,症结全在这个参数上。标记改由 X-APGT-Source * 请求头单独承担,前台照样能认出「这是后台在抓原文」,不替换译文、不递归。 * * @param string $url 页面 URL。 * @return array [ 'ok'=>bool, 'html'=>string, 'message'=>string, 'debug'=>array ] */ public static function fetch_html( $url ) { $request_url = $url; // 抓取失败最难查的是「请求到底打到哪去了」:同一个地址在浏览器里好好的, // 服务器自己回环访问却 404。差别都藏在 curl 那一层——域名解析到了哪个 IP、 // 中途有没有跳转、最终请求的还是不是这个地址。借 http_api_curl 把句柄留下来, // 请求结束后取 info 一并回传,省得每次靠猜。非 curl 传输时句柄为 null,跳过即可。 $handle = null; $keep = static function ( $curl ) use ( &$handle, $request_url ) { $handle = $curl; self::pin_host_ip( $curl, $request_url ); }; add_action( 'http_api_curl', $keep ); $response = wp_remote_get( $request_url, array( 'timeout' => 30, 'redirection' => 3, 'sslverify' => false, 'headers' => array( 'X-APGT-Source' => '1', // 地址上不再挂参数,也就没了「参数不同=另一个页面」的副作用, // 缓存插件有可能把存着的旧页面给我们。明确要一份新的。 'Cache-Control' => 'no-cache', ), ) ); remove_action( 'http_api_curl', $keep ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'html' => '', /* translators: %s: error message */ 'message' => sprintf( __( 'Could not fetch the page: %s', 'aipogeo-context-translate' ), $response->get_error_message() ), 'debug' => self::fetch_debug( $request_url, $response, $handle ), ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return array( 'ok' => false, 'html' => '', /* translators: %d: HTTP status code */ 'message' => sprintf( __( 'Could not fetch the page, HTTP %d', 'aipogeo-context-translate' ), $code ), 'debug' => self::fetch_debug( $request_url, $response, $handle ), ); } return array( 'ok' => true, 'html' => wp_remote_retrieve_body( $response ), 'message' => '', 'debug' => array(), ); } /** * 设置页填的「抓取用 IP」,没填或填得不合法时返回空串。 * * @return string */ public static function fetch_ip() { $settings = APGT_Settings::get_settings(); $ip = isset( $settings['fetch_ip'] ) ? trim( (string) $settings['fetch_ip'] ) : ''; if ( '' === $ip ) { return ''; } // 存的时候已经校验过,这里再挡一道:库里可能留着旧数据或别处写进来的值。 $valid = rest_is_ip_address( $ip ); return $valid ? $valid : ''; } /** * 可选:把抓取用的域名钉到指定 IP(CURLOPT_RESOLVE)。 * * 服务器访问自己站点的域名走的是公网 DNS,这一段最容易出岔子:解析被劫持、 * 机房限制出网、域名指向 CDN 而 CDN 回不到本机——结果就是抓取超时,或者 * 拿回一个别人给的 404。钉住 IP 能整段绕开。 * * 但默认不启用,也不猜:「填 127.0.0.1 总没错」并不成立。PHP 跑在容器里时 * 127.0.0.1 是容器自己,Web 服务在另一个容器或另一台机器上,钉过去直接连不上。 * 只有站点自己知道该填哪个 IP,所以留给设置页的「抓取用 IP」,留空就照常走 DNS。 * * 用 CURLOPT_RESOLVE 而不是把地址里的域名换成 IP:Host 头与 https 的 SNI * 都保持原域名,否则一台机器上跑多个站点时会落到默认 server 块,照样 404。 * * @param mixed $handle curl 句柄。 * @param string $url 请求地址。 * @return void */ private static function pin_host_ip( $handle, $url ) { if ( ! ( $handle instanceof CurlHandle ) || ! function_exists( 'curl_setopt' ) ) { return; } /** * 抓取站内页面时,把域名固定解析到哪个 IP。 * * 默认取设置页填的值,空串表示照常走系统 DNS。 * * @param string $ip 目标 IP。 * @param string $url 即将抓取的地址。 */ $ip = apply_filters( 'apgt_fetch_resolve_ip', self::fetch_ip(), $url ); if ( ! is_string( $ip ) || '' === trim( $ip ) ) { return; } $parts = wp_parse_url( $url ); if ( empty( $parts['host'] ) ) { return; } $scheme = isset( $parts['scheme'] ) ? $parts['scheme'] : 'http'; $port = empty( $parts['port'] ) ? ( 'https' === $scheme ? 443 : 80 ) : (int) $parts['port']; // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- 请求本身仍走 WP HTTP API,这里只补一条解析规则。 curl_setopt( $handle, CURLOPT_RESOLVE, array( $parts['host'] . ':' . $port . ':' . trim( $ip ) ) ); } /** * 抓取失败时的现场信息,供后台原样回传给管理员看。 * * 只在失败分支调用,成功抓取不做这些额外解析。 * * @param string $request_url 实际请求的地址。 * @param array|WP_Error $response wp_remote_get 的返回。 * @param mixed $handle curl 句柄;非 curl 传输时为 null。 * @return array */ private static function fetch_debug( $request_url, $response, $handle ) { $debug = array( 'request_url' => $request_url, 'home_url' => home_url( '/' ), 'transport' => ( $handle instanceof CurlHandle ) ? 'curl' : 'other', // 设置页填了 IP 的话记一笔:curl 里 primary_ip 是不是它,一看便知有没有生效。 'pinned_ip' => self::fetch_ip(), ); if ( is_wp_error( $response ) ) { $errors = array(); foreach ( $response->get_error_codes() as $code ) { $errors[ $code ] = $response->get_error_message( $code ); } $debug['wp_error'] = $errors; } else { $body = (string) wp_remote_retrieve_body( $response ); $headers = wp_remote_retrieve_headers( $response ); $debug['http_code'] = (int) wp_remote_retrieve_response_code( $response ); $debug['http_message'] = wp_remote_retrieve_response_message( $response ); $debug['headers'] = is_object( $headers ) && method_exists( $headers, 'getAll' ) ? $headers->getAll() : (array) $headers; $debug['body_length'] = strlen( $body ); $debug['page_title'] = preg_match( '#