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( '#]*>(.*?)#is', $body, $m ) ? trim( wp_strip_all_tags( $m[1] ) ) : ''; // 前几百字节足够认出是主题模板、nginx 默认页还是防护插件的拦截页。 $debug['body_excerpt'] = mb_substr( $body, 0, 500, 'UTF-8' ); // 这一条最能定位问题:false 说明请求压根没走到 WordPress, // 那 404 是 Web 服务器或中间层给的,跟页面在不在没关系。 $debug['served_by_wordpress'] = self::looks_like_wordpress( $body, $debug['headers'] ); } if ( $handle instanceof CurlHandle && function_exists( 'curl_getinfo' ) ) { try { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo -- 只读上一次请求的连接信息,请求本身仍走 WP HTTP API。 $info = curl_getinfo( $handle ); } catch ( Throwable $e ) { $info = array( 'error' => $e->getMessage() ); } if ( is_array( $info ) ) { // 证书链又长又和排障无关。 unset( $info['certinfo'] ); $debug['curl'] = $info; } } return $debug; } /** * 这个响应是不是 WordPress 自己给的。 * * @param string $body 响应体。 * @param array $headers 响应头(小写键)。 * @return bool */ private static function looks_like_wordpress( $body, array $headers ) { foreach ( array( 'x-redirect-by', 'x-pingback' ) as $name ) { if ( ! empty( $headers[ $name ] ) ) { return true; } } if ( ! empty( $headers['link'] ) && false !== strpos( (string) wp_json_encode( $headers['link'] ), '/wp-json/' ) ) { return true; } return (bool) preg_match( '#wp-(content|includes|json)/#', $body ); } /** * 把抓取现场压成一行,附在错误文案后面。 * * 面向管理员排障,键名保持英文原样,不做本地化。 * * @param array $debug fetch_debug() 的产出。 * @return string 空数组时返回空串。 */ public static function debug_summary( array $debug ) { if ( empty( $debug ) ) { return ''; } $bits = array(); if ( ! empty( $debug['curl']['url'] ) && $debug['curl']['url'] !== $debug['request_url'] ) { $bits[] = 'final ' . $debug['curl']['url']; } if ( ! empty( $debug['curl']['primary_ip'] ) ) { $bits[] = 'ip ' . $debug['curl']['primary_ip']; } if ( ! empty( $debug['curl']['redirect_count'] ) ) { $bits[] = 'redirects ' . (int) $debug['curl']['redirect_count']; } if ( isset( $debug['served_by_wordpress'] ) ) { $bits[] = $debug['served_by_wordpress'] ? 'served by WordPress' : 'not served by WordPress'; } return $bits ? ' [' . implode( ', ', $bits ) . ']' : ''; } /** * 把一组文本(md5 => 原文)分批交给 AI 翻译。 * * @param array $texts md5 => 原文。 * @param string $from 源语言。 * @param string $to 目标语言。 * @param array $text_replace 字符串替换规则。 * @return array [ 'trans'=>[md5=>译文], 'errors'=>array ] */ public static function translate_texts( array $texts, $from, $to, array $text_replace = array() ) { $trans = array(); $errors = array(); // URL 路径和正文分开送翻:两者的要求差得太远(路径要保持斜杠层级、不能带 // 空格、扩展名与查询串得原样留着),混在一批里再好的指令也说不清楚。 // 分开之后正文指令不用写 URL 相关的要求,路径条目也不必受正文指令约束。 $groups = array( 'text' => array(), 'uri' => array(), ); foreach ( $texts as $md5 => $text ) { $groups[ self::is_uri_text( $text ) ? 'uri' : 'text' ][ $md5 ] = $text; } // 一页能不能一次送完,取决于「最大输出 token」换算出的字符上限。 $max_length = self::batch_length( $to ); foreach ( $groups as $kind => $group ) { foreach ( self::split_batches( $group, $max_length ) as $batch ) { self::run_batch( $batch, $from, $to, $text_replace, $trans, $errors, 0, $kind ); } } return array( 'trans' => $trans, 'errors' => $errors, ); } /** * 这条原文是不是站内 URL 路径。 * * 判断标准与 APGT_Html::split_site_url() 的产出对齐:站内路径一律以 `/` * 开头,且地址里不会有空白字符(有也早被百分号编码了)。 * * @param string $text 原文。 * @return bool */ public static function is_uri_text( $text ) { $text = (string) $text; return isset( $text[0] ) && '/' === $text[0] && ! preg_match( '/\s/u', $text ); } /** * 把 AI 给的路径译文收拾成能直接当地址用的样子。 * * 指令里都写清楚了,但模型仍会时不时带上域名、翻出空格、把结尾斜杠丢掉, * 而这些地址是要写进 href 的,错一个字符就是 404,所以这里再兜一道。 * * @param string $src 原文路径。 * @param string $trans AI 返回的译文。 * @return string 收拾后的路径;实在用不了时返回空串,由调用方当作没译出来。 */ private static function normalize_uri( $src, $trans ) { $path = trim( (string) $trans ); if ( '' === $path ) { return ''; } // 首页不存在「翻译过的路径」,AI 给什么都不作数。 $src = (string) $src; if ( '/' === $src ) { return '/'; } // 连域名一起给回来的,剥掉 scheme://host[:port]。只认带 scheme 的形态: // 光看 // 开头没法和「多写了个斜杠」区分,按协议相对地址剥会把第一段吃掉。 $path = preg_replace( '@^[a-z][a-z0-9+.\-]*://[^/]*@i', '', $path ); // 反斜杠当分隔符、词间翻出空格,都是地址里不能留的。 $path = str_replace( '\\', '/', $path ); $path = preg_replace( '/\s+/u', '-', $path ); $path = preg_replace( '@/{2,}@', '/', $path ); if ( '' === $path ) { return ''; } if ( '/' !== $path[0] ) { $path = '/' . $path; } // 查询串与锚点是给程序看的,一律以原文为准,AI 动过的部分丢掉。 $src_cut = strcspn( $src, '?#' ); $src_path = substr( $src, 0, $src_cut ); $src_tail = substr( $src, $src_cut ); $path = substr( $path, 0, strcspn( $path, '?#' ) ); // 结尾斜杠跟着原文走:站点固定链接带不带斜杠是固定的,差一个就多一次跳转。 $path = rtrim( $path, '/' ); if ( '' === $path ) { $path = '/'; } elseif ( '/' !== $src_path && '/' === substr( $src_path, -1 ) ) { $path .= '/'; } return $path . $src_tail; } /** * 一次请求最多送多少字符原文。 * * 约束的其实是模型的输出长度:译文长度与原文相当,原文送多少,译文就得吐 * 多少。所以「最大输出 token」填了值就按它换算,没填(0)就用内置默认值, * 跟着模型自己的内置上限走。 * * @param string $to 目标语言 iso,决定 token 与字符的换算比例。 * @return int 字符数。 */ public static function batch_length( $to = '' ) { $ai = APGT_Settings::get_ai(); $max = isset( $ai['max_tokens'] ) ? (int) $ai['max_tokens'] : 0; if ( $max <= 0 ) { $max = self::DEFAULT_MAX_TOKENS; } return max( self::MIN_BATCH_LENGTH, (int) round( $max * self::chars_per_token( $to ) ) ); } /** * 目标语言的 1 token 大约折合几个字符。 * * 译文是用目标语言写的,换算当然要按目标语言来:同样 1000 token,写成中文 * 是一千五百来字,写成英文是四千个字符。 * * @param string $to 目标语言 iso。 * @return float */ public static function chars_per_token( $to ) { $iso = strtolower( (string) $to ); foreach ( array( 'zh', 'ja', 'ko', 'yue' ) as $prefix ) { if ( $iso === $prefix || 0 === strpos( $iso, $prefix . '-' ) ) { return self::CHARS_PER_TOKEN_CJK; } } return self::CHARS_PER_TOKEN_LATIN; } /** * 按总字符数与条数把文本拆成若干批。 * * @param array $texts md5 => 原文。 * @param int $max_length 单批字符上限,见 batch_length()。 * @return array 批次数组,每项为 md5 => 原文。 */ private static function split_batches( array $texts, $max_length ) { $max_length = max( 1, (int) $max_length ); $batches = array(); $cur = array(); $cur_len = 0; foreach ( $texts as $md5 => $text ) { $len = mb_strlen( $text, 'UTF-8' ); if ( $cur && ( ( $cur_len + $len ) > $max_length || count( $cur ) >= self::MAX_BATCH_ITEMS ) ) { $batches[] = $cur; $cur = array(); $cur_len = 0; } $cur[ $md5 ] = $text; $cur_len += $len; } if ( $cur ) { $batches[] = $cur; } return $batches; } /** * 把一批交给 AI,并对截断/漏译造成的缺失条目缩小规模后补译。 * * AI 输出撞上 max_tokens 时 JSON 会被拦腰切断,translate_batch() 已抢救出 * 切断点之前的译文,这里负责把剩下没拿到译文的条目重新发一遍: * 上一轮有产出就直接用缺失集重试(规模自然变小),一条都没拿到才拆半。 * * @param array $batch md5 => 原文。 * @param string $from 源语言。 * @param string $to 目标语言。 * @param array $text_replace 字符串替换规则。 * @param array $trans 译文累加器(引用)。 * @param array $errors 错误累加器(引用)。 * @param int $depth 当前递归层数。 * @param string $kind 批次类型:text 正文 / uri URL 路径。 * @return void */ private static function run_batch( array $batch, $from, $to, array $text_replace, array &$trans, array &$errors, $depth, $kind = 'text' ) { $res = self::translate_batch( $batch, $from, $to, $kind ); /** * 一次 AI 请求刚结束(无论成败)。 * * APGT_Lock::keep_alive() 靠它给页面锁续期:整页翻译由多次请求组成, * 总耗时没上限,但相邻两次请求的间隔不会超过单次请求的超时设置, * 锁龄以此为界就能区分「还在干活」和「持有者已经死了」。 */ do_action( 'apgt_ai_request_done' ); if ( ! $res['ok'] ) { // 整批没有任何可用内容:可能是这批太大,拆半再试(失败路径收敛得更快,免得空烧请求)。 if ( empty( $res['fatal'] ) && count( $batch ) > 1 && $depth < self::MAX_FAIL_SPLIT_DEPTH ) { foreach ( self::split_half( $batch ) as $half ) { self::run_batch( $half, $from, $to, $text_replace, $trans, $errors, $depth + 1, $kind ); } return; } $errors[] = $res['message']; return; } $accepted = array(); foreach ( $res['trans'] as $md5 => $t ) { if ( ! isset( $batch[ $md5 ] ) ) { continue; } if ( 'uri' === $kind ) { // 路径译文得能直接当地址用,AI 跑偏的(带上域名、翻出空格)在这里收拾; // 收拾不回来就当这条没译出来,交给下面的缺失重试。 $t = self::normalize_uri( $batch[ $md5 ], $t ); if ( '' === $t ) { continue; } } elseif ( $text_replace ) { // 字符串替换规则是给正文用的,路径套上去会把地址改坏。 $t = apgt_apply_text_replace( $t, $text_replace ); } $trans[ $md5 ] = $t; $accepted[ $md5 ] = $t; } $missing = array_diff_key( $batch, $accepted ); if ( empty( $missing ) ) { return; } if ( $depth >= self::MAX_SPLIT_DEPTH ) { /* translators: %d: number of items */ $errors[] = sprintf( __( '%d texts still came back without a translation after retrying', 'aipogeo-context-translate' ), count( $missing ) ); return; } // 有产出:缺失集本身就比原批小,整体重试即可。 if ( count( $missing ) < count( $batch ) ) { self::run_batch( $missing, $from, $to, $text_replace, $trans, $errors, $depth + 1, $kind ); return; } // 一条都没拿到,只有拆小才可能有变化。 if ( count( $batch ) > 1 ) { foreach ( self::split_half( $batch ) as $half ) { self::run_batch( $half, $from, $to, $text_replace, $trans, $errors, $depth + 1, $kind ); } return; } $errors[] = __( 'The AI returned no translation for a single-text request', 'aipogeo-context-translate' ); } /** * 把一批按条数对半分成两份。 * * @param array $batch md5 => 原文。 * @return array 两个批次(原批只有一条时返回一个)。 */ private static function split_half( array $batch ) { $count = count( $batch ); if ( $count < 2 ) { return array( $batch ); } $half = (int) ceil( $count / 2 ); return array( array_slice( $batch, 0, $half, true ), array_slice( $batch, $half, null, true ), ); } /** * 单批 AI 翻译请求。 * * 用短序号做 JSON 键(省 token),内部维护 序号 => md5 映射还原。 * * @param array $batch md5 => 原文。 * @param string $from 源语言。 * @param string $to 目标语言。 * @param string $kind 批次类型:text 正文 / uri URL 路径,决定用哪条指令。 * @return array [ 'ok'=>bool, 'message'=>string, 'trans'=>[md5=>译文], 'truncated'=>bool ] * ok=true 也可能只拿到部分译文(输出被截断),由调用方比对缺失条目补译。 */ private static function translate_batch( array $batch, $from, $to, $kind = 'text' ) { $ai_cfg = APGT_Settings::get_ai(); // 三条路互斥:订阅由服务端代调模型,站点级 AI 用 WordPress 自己的凭据, // 剩下的用插件里填的 Key。选了订阅就不该再落到后两条上 —— 用户买了订阅, // 翻译却悄悄走了别处、花了别的钱,是最难排查的那种错。 $use_service = 'service' === ( isset( $ai_cfg['provider'] ) ? (string) $ai_cfg['provider'] : '' ); $use_core = ! $use_service && self::use_core_ai(); if ( $use_service ) { if ( ! APGT_Service::in_use() ) { return array( 'ok' => false, 'message' => __( 'No subscription is connected yet. Press "Connect to Aipogeo" in the settings.', 'aipogeo-context-translate' ), 'trans' => array(), 'truncated' => false, 'fatal' => true, // 配置问题,拆批重试没有意义。 ); } // 额度先看本地快照,不够就直接停,别把请求打到服务端再被拒掉。 // 真正的判定以服务端为准 —— 那边扣减走单条 UPDATE,状态、有效期、 // 余额三重校验都在 WHERE 里,并发也不会超扣。 if ( APGT_Service::out_of_quota() ) { return array( 'ok' => false, 'message' => __( 'The subscription quota is used up. Upgrade the plan or wait for the next period.', 'aipogeo-context-translate' ), 'trans' => array(), 'truncated' => false, 'fatal' => true, // 额度问题,拆批重试没有意义。 ); } } elseif ( $use_core ) { if ( ! self::core_ai_available() ) { return array( 'ok' => false, 'message' => __( 'This site has no AI provider configured yet. Set one up under Settings, or switch this plugin back to its own AI service settings.', 'aipogeo-context-translate' ), 'trans' => array(), 'truncated' => false, 'fatal' => true, // 配置问题,拆批重试没有意义。 ); } } else { self::boot_ai(); if ( '' === trim( (string) $ai_cfg['api_key'] ) ) { return array( 'ok' => false, 'message' => __( 'No AI API key configured', 'aipogeo-context-translate' ), 'trans' => array(), 'truncated' => false, 'fatal' => true, // 配置问题,拆批重试没有意义。 ); } } // 组织 { 序号: 原文 } 与 序号 => md5。 $seq_to_md5 = array(); $data = array(); $i = 0; foreach ( $batch as $md5 => $text ) { ++$i; $seq_to_md5[ $i ] = $md5; $data[ $i ] = $text; } // 渲染指令:URL 路径用专用那条,正文用用户配置的那条。 $prompt = APGT_Prompt::render( 'uri' === $kind ? APGT_Settings::get_url_prompt() : APGT_Settings::get_prompt(), array( 'language_from' => self::lang_label( $from ), 'language_to' => self::lang_label( $to ), 'translate_data' => wp_json_encode( $data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), ) ); // 调 AI。 if ( $use_service ) { // 订阅:指令交给服务端,由它带自己的凭据去调模型。本站不持有 AI Key, // 用量也由服务端按收发字符数算,所以这条路径不再上报 consume()。 $res = APGT_Service::translate( $prompt, (int) $ai_cfg['max_tokens'] ); if ( ! $res['ok'] ) { APGT_Service::remember_error( APGT_Service::explain( $res ) ); return array( 'ok' => false, 'message' => APGT_Service::explain( $res ), 'trans' => array(), 'truncated' => false, 'fatal' => APGT_Service::is_fatal( $res ), ); } $content = trim( (string) ( isset( $res['data']['content'] ) ? $res['data']['content'] : '' ) ); $truncated = apgt_is_truncated_finish( isset( $res['data']['finish_reason'] ) ? (string) $res['data']['finish_reason'] : '' ); } elseif ( $use_core ) { $result = self::ask_core_ai( $prompt, (int) $ai_cfg['max_tokens'] ); if ( ! $result['ok'] ) { return array( 'ok' => false, 'message' => $result['message'], 'trans' => array(), 'truncated' => false, 'fatal' => $result['fatal'], ); } $content = $result['content']; // 核心的 AI Client 只回文本,拿不到 finish_reason。输出被截断的情况 // 交给下面的 JSON 解析兜底:解不动时按残缺处理,缺的条目由上层补译。 $truncated = false; } else { try { // 接口地址与协议留空时跟随 php-ai 库里该模型的内置值。 $ai = \Ai\AI::create( self::ai_config( $ai_cfg ) ); if ( ! empty( $ai_cfg['proxy'] ) ) { $ai->setProxy( $ai_cfg['proxy'] ); } if ( (int) $ai_cfg['timeout'] > 0 ) { $ai->setTimeout( (int) $ai_cfg['timeout'] ); } $resp = $ai->chat( $prompt ); $content = trim( (string) $resp->getContent() ); $raw = $resp->getRaw(); } catch ( \Throwable $e ) { $err = self::request_error( $e->getMessage() ); return array( 'ok' => false, 'message' => $err['message'], 'trans' => array(), 'truncated' => false, 'fatal' => $err['fatal'], ); } $truncated = apgt_is_truncated_finish( apgt_ai_finish_reason( $raw ) ); } if ( '' === $content ) { return array( 'ok' => false, 'message' => __( 'The AI returned an empty response', 'aipogeo-context-translate' ), 'trans' => array(), 'truncated' => $truncated, ); } // 解析 JSON。 $content = apgt_ai_response_filter( $content ); $decoded = apgt_is_bad_json( $content ) ? null : json_decode( $content, true ); if ( ! is_array( $decoded ) ) { // 整体解析失败最常见的原因是输出撞上 max_tokens 被拦腰切断, // 切断点之前的译文仍然完整可用,抢救出来,缺的交给上层补译。 $decoded = apgt_json_partial_decode( $content ); if ( empty( $decoded ) ) { return array( 'ok' => false, 'message' => ( $truncated ? __( 'The AI response was cut off by the length limit and contained nothing usable', 'aipogeo-context-translate' ) : __( 'The AI response is not valid JSON', 'aipogeo-context-translate' ) ) . apgt_ai_raw_tip( $content ), 'trans' => array(), 'truncated' => $truncated, ); } $truncated = true; if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log sprintf( '[APGT] AI response was truncated; salvaged %1$d/%2$d translations, the rest will be re-sent in smaller batches.', count( $decoded ), count( $batch ) ) ); } } // 序号 => 译文 还原为 md5 => 译文。 $trans = array(); foreach ( $decoded as $seq => $t ) { $seq = (int) $seq; if ( ! isset( $seq_to_md5[ $seq ] ) ) { continue; } $t = str_replace( '\\/', '/', trim( (string) $t ) ); // 兜底清理 AI 残留的 \/ 转义。 if ( '' === $t ) { continue; } $trans[ $seq_to_md5[ $seq ] ] = $t; } // 订阅路径不在这里报用量:模型是服务端调的,它按实际收发的字符数当场扣, // 比本站自报准,也不给篡改留口子。额度快照已随那次响应刷新过了。 return array( 'ok' => true, 'message' => '', 'trans' => $trans, 'truncated' => $truncated, ); } /** * 取语言在指令中使用的可读名称(英文名优先,回退 iso)。 * * @param string $iso 语言代码。 * @return string */ private static function lang_label( $iso ) { $meta = APGT_Settings::language_meta(); if ( isset( $meta[ $iso ]['name_en'] ) && '' !== $meta[ $iso ]['name_en'] ) { return $meta[ $iso ]['name_en'] . ' (' . $iso . ')'; } return $iso; } /** * 取目标语言的字符串替换规则。 * * @param string $to 目标语言。 * @return array */ public static function text_replace_rules( $to ) { $targets = APGT_Settings::target_languages(); if ( isset( $targets[ $to ]['text_replace'] ) && is_array( $targets[ $to ]['text_replace'] ) ) { return $targets[ $to ]['text_replace']; } return array(); } /** * 组装统一返回结构。 * * @param bool $ok 是否成功。 * @param string $message 消息。 * @param int $saved 成功条数。 * @param int $skipped 跳过条数。 * @param int $failed 失败条数。 * @param array $errors 错误明细。 * @return array */ private static function result( $ok, $message, $saved = 0, $skipped = 0, $failed = 0, $errors = array() ) { return array( 'ok' => (bool) $ok, 'message' => $message, 'saved' => (int) $saved, 'skipped' => (int) $skipped, 'failed' => (int) $failed, 'errors' => (array) $errors, ); } }