字符串值」对。 * * AI 输出超出 max_tokens 时会被拦腰切断,json_decode 整体失败, * 但切断点之前的键值对是完整可用的,没必要整批丢弃重译。 * 只匹配收尾引号齐全、且后面紧跟 , } ] 或字符串结尾的对; * 落在切断点上的半截值因为缺收尾引号不会被匹配到。 * * @param string $str JSON 文本(可能不完整)。 * @return array 键 => 值(均已还原转义)。 */ function apgt_json_partial_decode( $str ) { $str = trim( (string) $str ); if ( '' === $str ) { return array(); } // "key" : "value",字符串体允许 \" 等转义序列。 $pattern = '/"((?:[^"\\\\]|\\\\.)*)"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"(?=\s*[,}\]]|\s*$)/s'; if ( ! preg_match_all( $pattern, $str, $matches, PREG_SET_ORDER ) ) { return array(); } $out = array(); foreach ( $matches as $m ) { $key = json_decode( '"' . $m[1] . '"' ); $val = json_decode( '"' . $m[2] . '"' ); if ( ! is_string( $key ) || ! is_string( $val ) || '' === $key ) { continue; } $out[ $key ] = $val; } return $out; } } if ( ! function_exists( 'apgt_ai_finish_reason' ) ) { /** * 从各平台原始响应中取出结束原因,统一为小写字符串。 * * OpenAI/DeepSeek:choices[0].finish_reason(截断为 length) * Claude:stop_reason(截断为 max_tokens) * Gemini:candidates[0].finishReason(截断为 MAX_TOKENS) * * @param array $raw 原始响应数组。 * @return string 取不到时返回空串。 */ function apgt_ai_finish_reason( $raw ) { if ( ! is_array( $raw ) ) { return ''; } $candidates = array( isset( $raw['choices'][0]['finish_reason'] ) ? $raw['choices'][0]['finish_reason'] : null, isset( $raw['stop_reason'] ) ? $raw['stop_reason'] : null, isset( $raw['candidates'][0]['finishReason'] ) ? $raw['candidates'][0]['finishReason'] : null, ); foreach ( $candidates as $reason ) { if ( is_string( $reason ) && '' !== $reason ) { return strtolower( $reason ); } } return ''; } } if ( ! function_exists( 'apgt_is_truncated_finish' ) ) { /** * 判断结束原因是否表示「输出被长度上限截断」。 * * @param string $reason apgt_ai_finish_reason() 的返回值。 * @return bool */ function apgt_is_truncated_finish( $reason ) { return in_array( (string) $reason, array( 'length', 'max_tokens', 'maxtokens' ), true ); } } if ( ! function_exists( 'apgt_ai_raw_tip' ) ) { /** * 生成 AI 翻译失败时附加到错误消息的返回数据摘要(截断)。 * * @param string $ai_raw AI 返回原始数据。 * @param int $max_length 摘要最大长度。 * @return string */ function apgt_ai_raw_tip( $ai_raw, $max_length = 200 ) { $ai_raw = trim( (string) $ai_raw ); if ( '' === $ai_raw ) { return ''; } if ( mb_strlen( $ai_raw, 'UTF-8' ) > $max_length ) { $ai_raw = mb_substr( $ai_raw, 0, $max_length, 'UTF-8' ) . '…'; } /* translators: %s: raw AI response, truncated. Note the leading space: this is appended to an error message. */ return sprintf( __( ' (AI returned: %s)', 'aipogeo-context-translate' ), $ai_raw ); } } if ( ! function_exists( 'apgt_language_names' ) ) { /** * 语言名的可翻译表(iso => 当前界面语言下的语言名)。 * * languages.php 里的 name_en 是固定英文名,这里逐条过一遍 __(), * 语言名就能跟着后台界面语言走。写成字面量数组是为了让 GlotPress 提取得到, * 也不缓存结果——语言包在 init 时才加载,缓存会把未翻译的值固化下来。 * * @return array */ function apgt_language_names() { return array( 'af' => __( 'Afrikaans', 'aipogeo-context-translate' ), 'ar' => __( 'Arabic', 'aipogeo-context-translate' ), 'az' => __( 'Azerbaijani', 'aipogeo-context-translate' ), 'bg' => __( 'Bulgarian', 'aipogeo-context-translate' ), 'bn' => __( 'Bengali', 'aipogeo-context-translate' ), 'cs' => __( 'Czech', 'aipogeo-context-translate' ), 'da' => __( 'Danish', 'aipogeo-context-translate' ), 'de' => __( 'German', 'aipogeo-context-translate' ), 'el' => __( 'Greek', 'aipogeo-context-translate' ), 'en' => __( 'English', 'aipogeo-context-translate' ), 'es' => __( 'Spanish', 'aipogeo-context-translate' ), 'et' => __( 'Amharic', 'aipogeo-context-translate' ), 'fa' => __( 'Persian', 'aipogeo-context-translate' ), 'fi' => __( 'Finnish', 'aipogeo-context-translate' ), 'fr' => __( 'French', 'aipogeo-context-translate' ), 'ga' => __( 'Irish', 'aipogeo-context-translate' ), 'gu' => __( 'Gujarati', 'aipogeo-context-translate' ), 'he' => __( 'Hebrew', 'aipogeo-context-translate' ), 'hi' => __( 'Hindi', 'aipogeo-context-translate' ), 'hr' => __( 'Croatian', 'aipogeo-context-translate' ), 'ht' => __( 'Haitian', 'aipogeo-context-translate' ), 'hu' => __( 'Hungarian', 'aipogeo-context-translate' ), 'hy' => __( 'Armenian', 'aipogeo-context-translate' ), 'id' => __( 'Indonesian', 'aipogeo-context-translate' ), 'is' => __( 'Icelandic', 'aipogeo-context-translate' ), 'it' => __( 'Italian', 'aipogeo-context-translate' ), 'iu' => __( 'Inuktitut', 'aipogeo-context-translate' ), 'ja' => __( 'Japanese', 'aipogeo-context-translate' ), 'km' => __( 'Central Khmer', 'aipogeo-context-translate' ), 'kn' => __( 'Kannada', 'aipogeo-context-translate' ), 'ko' => __( 'Korean', 'aipogeo-context-translate' ), 'ku' => __( 'Kurdish', 'aipogeo-context-translate' ), 'lo' => __( 'Lao', 'aipogeo-context-translate' ), 'lt' => __( 'Lithuanian', 'aipogeo-context-translate' ), 'lv' => __( 'Latvian', 'aipogeo-context-translate' ), 'mg' => __( 'Malagasy', 'aipogeo-context-translate' ), 'mi' => __( 'Maori', 'aipogeo-context-translate' ), 'ml' => __( 'Malayalam', 'aipogeo-context-translate' ), 'mr' => __( 'Marathi', 'aipogeo-context-translate' ), 'ms' => __( 'Malay', 'aipogeo-context-translate' ), 'mt' => __( 'Maltese', 'aipogeo-context-translate' ), 'my' => __( 'Burmese', 'aipogeo-context-translate' ), 'ne' => __( 'Nepali', 'aipogeo-context-translate' ), 'nl' => __( 'Dutch', 'aipogeo-context-translate' ), 'no' => __( 'Norwegian', 'aipogeo-context-translate' ), 'or' => __( 'Oriya', 'aipogeo-context-translate' ), 'pa' => __( 'Punjabi', 'aipogeo-context-translate' ), 'pl' => __( 'Polish', 'aipogeo-context-translate' ), 'ps' => __( 'Pashto', 'aipogeo-context-translate' ), 'pt' => __( 'Portuguese', 'aipogeo-context-translate' ), 'ro' => __( 'Romanian', 'aipogeo-context-translate' ), 'ru' => __( 'Russian', 'aipogeo-context-translate' ), 'sk' => __( 'Slovak', 'aipogeo-context-translate' ), 'sl' => __( 'Slovenian', 'aipogeo-context-translate' ), 'sm' => __( 'Samoan', 'aipogeo-context-translate' ), 'sq' => __( 'Albanian', 'aipogeo-context-translate' ), 'sv' => __( 'Swedish', 'aipogeo-context-translate' ), 'sw' => __( 'Swahili', 'aipogeo-context-translate' ), 'ta' => __( 'Tamil', 'aipogeo-context-translate' ), 'te' => __( 'Telugu', 'aipogeo-context-translate' ), 'th' => __( 'Thai', 'aipogeo-context-translate' ), 'to' => __( 'Tonga', 'aipogeo-context-translate' ), 'tr' => __( 'Turkish', 'aipogeo-context-translate' ), 'uk' => __( 'Ukrainian', 'aipogeo-context-translate' ), 'ur' => __( 'Urdu', 'aipogeo-context-translate' ), 'vi' => __( 'Vietnamese', 'aipogeo-context-translate' ), 'zh-Hans' => __( 'Chinese Simplified', 'aipogeo-context-translate' ), 'zh-Hant' => __( 'Chinese Traditional', 'aipogeo-context-translate' ), ); } } if ( ! function_exists( 'apgt_language_name' ) ) { /** * 语言名,表里没有就退回语言代码。 * * @param string $iso 语言代码。 * @return string */ function apgt_language_name( $iso ) { $names = apgt_language_names(); return isset( $names[ $iso ] ) ? $names[ $iso ] : (string) $iso; } } if ( ! function_exists( 'apgt_language_label' ) ) { /** * 后台列表用的语言标签,形如「English (en)」。 * * 括号本身也交给译者:中文用全角括号,英文用半角。 * * @param string $iso 语言代码。 * @return string */ function apgt_language_label( $iso ) { $names = apgt_language_names(); if ( ! isset( $names[ $iso ] ) ) { return (string) $iso; } return sprintf( /* translators: 1: language name 2: ISO language code */ _x( '%1$s (%2$s)', 'language name with code', 'aipogeo-context-translate' ), $names[ $iso ], $iso ); } } if ( ! function_exists( 'apgt_sanitize_translatable_text' ) ) { /** * 清理一段「可翻译文本」:页面原文、译文,以及发给 AI 的翻译指令。 * * 这三类内容都不能交给 sanitize_text_field() / sanitize_textarea_field()。 * 那两个函数内部会走 wp_strip_all_tags(),而 PHP 的 strip_tags() 见到一个 * 孤立的 `<` 就把它到字符串末尾的内容整段吞掉: * * strip_tags( '价格 <100 元' ) === '价格 ' * * 网页正文里「<100」「1<2」这种写法很常见,一旦原文被改写,它的 md5 就与 * 前台从页面上提取出来的文本对不上,那一条从此永远匹配不到译文;译文被改写 * 则是直接在读者眼前少半句话。所以这里按内容的真实形态收紧,做三件事: * * 1. 剔除非法 UTF-8 字节序列(wp_check_invalid_utf8 的 strip 模式), * 避免脏字节进到数据库和 json_encode()。 * 2. 去掉除 \t \n \r 之外的 C0/C1 控制字符与零宽换向符,它们在页面上不可见, * 却能让译文和原文看起来一样却算出不同的 md5。 * 3. 按上限截断,挡住超长请求体撑爆内存。 * * 输出侧不依赖这一步:译文回填由 DOMDocument 写进属性或文本节点,转义由它负责; * 后台回显走 esc_textarea() / esc_html();写库走 $wpdb 预处理。 * * @param mixed $value 待清理的值。 * @param int $max_length 最大字符数,0 表示不限制。 * @return string */ function apgt_sanitize_translatable_text( $value, $max_length = 0 ) { if ( ! is_scalar( $value ) ) { return ''; } $value = wp_check_invalid_utf8( (string) $value, true ); // C0(保留 \t \n \r)、DEL、C1,以及 U+200E/U+200F 这类不可见换向符。 $value = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]|\xE2\x80[\x8E\x8F\xAA-\xAE]/', '', $value ); $max_length = (int) $max_length; if ( $max_length > 0 && mb_strlen( $value, 'UTF-8' ) > $max_length ) { $value = mb_substr( $value, 0, $max_length, 'UTF-8' ); } return trim( $value ); } }